From 6a262dac192b017f60becfeb370aa1c4ab83df0b Mon Sep 17 00:00:00 2001 From: Pauljd Date: Wed, 4 Nov 2026 22:17:58 +0000 Subject: [PATCH] Play H.264 in the old VfW MKV wrapper (Dragon Ball GT): Orbit's copy of Media3's Matroska reader Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tv/orbit/classic/EmbyPlaybackDialog.kt | 2 +- .../java/tv/orbit/classic/GuidePicture.kt | 2 +- .../src/main/java/tv/orbit/classic/Idents.kt | 2 +- .../java/tv/orbit/classic/OrbitExtractors.kt | 43 + .../orbit/classic/mkv/DefaultEbmlReader.java | 256 ++ .../tv/orbit/classic/mkv/EbmlProcessor.java | 159 + .../java/tv/orbit/classic/mkv/EbmlReader.java | 55 + .../orbit/classic/mkv/MatroskaExtractor.java | 2679 +++++++++++++++++ .../java/tv/orbit/classic/mkv/Sniffer.java | 112 + .../tv/orbit/classic/mkv/VarintReader.java | 150 + .../tv/orbit/classic/OrbitMatroskaTest.kt | 29 + 11 files changed, 3486 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/OrbitExtractors.kt create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/DefaultEbmlReader.java create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/EbmlProcessor.java create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/EbmlReader.java create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/MatroskaExtractor.java create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/Sniffer.java create mode 100644 android/app/src/main/java/tv/orbit/classic/mkv/VarintReader.java create mode 100644 android/app/src/test/java/tv/orbit/classic/OrbitMatroskaTest.kt diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index c90f061..366b3ac 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -101,7 +101,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl val player = remember(session, plan) { if (runningPlayer != null) return@remember runningPlayer val factory = DataSource.Factory { SecureMediaDataSource(session) } - ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory)) + ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())) .setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply { setMediaItem(channelMediaItem(plan)) if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs())) diff --git a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt index 5cc7c92..57a545a 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -118,7 +118,7 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, val player = remember(session, plan) { if (runningPlayer != null) return@remember runningPlayer val factory = DataSource.Factory { SecureMediaDataSource(session) } - ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).build().apply { + ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).build().apply { setMediaItem(channelMediaItem(plan)) seekTo(programme.offsetAt(System.currentTimeMillis())) trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage("en").build() diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index cee8ece..0d37b06 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -78,7 +78,7 @@ internal class LoadedNext(val startMs: Long, val plan: EmbyPlaybackPlan, private @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) internal fun loadNextPlayer(context: Context, session: EmbySampleSession, plan: EmbyPlaybackPlan, audio: String?): ExoPlayer { val factory = DataSource.Factory { SecureMediaDataSource(session) } - return ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply { + return ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply { setMediaItem(channelMediaItem(plan)) trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage(audio ?: "en").build() prepare(); playWhenReady = false diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitExtractors.kt b/android/app/src/main/java/tv/orbit/classic/OrbitExtractors.kt new file mode 100644 index 0000000..b644f38 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/OrbitExtractors.kt @@ -0,0 +1,43 @@ +package tv.orbit.classic + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import androidx.media3.extractor.text.SubtitleParser + +/** + * Media3's own extractors, with its Matroska reader swapped for Orbit's copy ([tv.orbit.classic.mkv.MatroskaExtractor]), + * which also reads H.264 in the old Video for Windows wrapper (Dragon Ball GT's files, 4 November 2026). Everything else, + * subtitles included, is configured exactly as Media3 would. + */ +@OptIn(UnstableApi::class) +internal class OrbitExtractorsFactory : ExtractorsFactory { + private val media3 = DefaultExtractorsFactory() + private var subtitles: SubtitleParser.Factory = DefaultSubtitleParserFactory() + private var transcoding = true + + internal fun swap(extractors: Array): Array = extractors.map { + if (it is androidx.media3.extractor.mkv.MatroskaExtractor) tv.orbit.classic.mkv.MatroskaExtractor(subtitles, + if (transcoding) 0 else tv.orbit.classic.mkv.MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA) else it + }.toTypedArray() + + override fun createExtractors(): Array = swap(media3.createExtractors()) + override fun createExtractors(uri: Uri, responseHeaders: Map>): Array = swap(media3.createExtractors(uri, responseHeaders)) + + @Deprecated("Media3's own deprecation") + override fun experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled: Boolean): ExtractorsFactory { + transcoding = textTrackTranscodingEnabled; media3.experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled); return this + } + + override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory): ExtractorsFactory { + subtitles = subtitleParserFactory; media3.setSubtitleParserFactory(subtitleParserFactory); return this + } + + override fun experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies: Int): ExtractorsFactory { + media3.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies); return this + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/DefaultEbmlReader.java b/android/app/src/main/java/tv/orbit/classic/mkv/DefaultEbmlReader.java new file mode 100644 index 0000000..9971742 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/DefaultEbmlReader.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +import static java.lang.annotation.ElementType.TYPE_USE; + +import androidx.annotation.IntDef; +import androidx.media3.common.C; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.Assertions; +import androidx.media3.extractor.ExtractorInput; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayDeque; + +/** Default implementation of {@link EbmlReader}. */ +/* package */ final class DefaultEbmlReader implements EbmlReader { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({ELEMENT_STATE_READ_ID, ELEMENT_STATE_READ_CONTENT_SIZE, ELEMENT_STATE_READ_CONTENT}) + private @interface ElementState {} + + private static final int ELEMENT_STATE_READ_ID = 0; + private static final int ELEMENT_STATE_READ_CONTENT_SIZE = 1; + private static final int ELEMENT_STATE_READ_CONTENT = 2; + + private static final int MAX_ID_BYTES = 4; + private static final int MAX_LENGTH_BYTES = 8; + + private static final int MAX_INTEGER_ELEMENT_SIZE_BYTES = 8; + private static final int VALID_FLOAT32_ELEMENT_SIZE_BYTES = 4; + private static final int VALID_FLOAT64_ELEMENT_SIZE_BYTES = 8; + + private final byte[] scratch; + private final ArrayDeque masterElementsStack; + private final VarintReader varintReader; + + private EbmlProcessor processor; + private @ElementState int elementState; + private int elementId; + private long elementContentSize; + + public DefaultEbmlReader() { + scratch = new byte[8]; + masterElementsStack = new ArrayDeque<>(); + varintReader = new VarintReader(); + } + + @Override + public void init(EbmlProcessor processor) { + this.processor = processor; + } + + @Override + public void reset() { + elementState = ELEMENT_STATE_READ_ID; + masterElementsStack.clear(); + varintReader.reset(); + } + + @Override + public boolean read(ExtractorInput input) throws IOException { + Assertions.checkStateNotNull(processor); + while (true) { + MasterElement head = masterElementsStack.peek(); + if (head != null && input.getPosition() >= head.elementEndPosition) { + processor.endMasterElement(masterElementsStack.pop().elementId); + return true; + } + + if (elementState == ELEMENT_STATE_READ_ID) { + long result = varintReader.readUnsignedVarint(input, true, false, MAX_ID_BYTES); + if (result == C.RESULT_MAX_LENGTH_EXCEEDED) { + result = maybeResyncToNextLevel1Element(input); + } + if (result == C.RESULT_END_OF_INPUT) { + return false; + } + // Element IDs are at most 4 bytes, so we can cast to integers. + elementId = (int) result; + elementState = ELEMENT_STATE_READ_CONTENT_SIZE; + } + + if (elementState == ELEMENT_STATE_READ_CONTENT_SIZE) { + elementContentSize = varintReader.readUnsignedVarint(input, false, true, MAX_LENGTH_BYTES); + elementState = ELEMENT_STATE_READ_CONTENT; + } + + @EbmlProcessor.ElementType int type = processor.getElementType(elementId); + switch (type) { + case EbmlProcessor.ELEMENT_TYPE_MASTER: + long elementContentPosition = input.getPosition(); + long elementEndPosition = elementContentPosition + elementContentSize; + masterElementsStack.push(new MasterElement(elementId, elementEndPosition)); + processor.startMasterElement(elementId, elementContentPosition, elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT: + if (elementContentSize > MAX_INTEGER_ELEMENT_SIZE_BYTES) { + throw ParserException.createForMalformedContainer( + "Invalid integer size: " + elementContentSize, /* cause= */ null); + } + processor.integerElement(elementId, readInteger(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_FLOAT: + if (elementContentSize != VALID_FLOAT32_ELEMENT_SIZE_BYTES + && elementContentSize != VALID_FLOAT64_ELEMENT_SIZE_BYTES) { + throw ParserException.createForMalformedContainer( + "Invalid float size: " + elementContentSize, /* cause= */ null); + } + processor.floatElement(elementId, readFloat(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_STRING: + if (elementContentSize > Integer.MAX_VALUE) { + throw ParserException.createForMalformedContainer( + "String element size: " + elementContentSize, /* cause= */ null); + } + processor.stringElement(elementId, readString(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_BINARY: + processor.binaryElement(elementId, (int) elementContentSize, input); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNKNOWN: + input.skipFully((int) elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + break; + default: + throw ParserException.createForMalformedContainer( + "Invalid element type " + type, /* cause= */ null); + } + } + } + + /** + * Does a byte by byte search to try and find the next level 1 element. This method is called if + * some invalid data is encountered in the parser. + * + * @param input The {@link ExtractorInput} from which data has to be read. + * @return id of the next level 1 element that has been found. + * @throws EOFException If the end of input was encountered when searching for the next level 1 + * element. + * @throws IOException If an error occurs reading from the input. + */ + private long maybeResyncToNextLevel1Element(ExtractorInput input) throws IOException { + input.resetPeekPosition(); + while (true) { + input.peekFully(scratch, 0, MAX_ID_BYTES); + int varintLength = VarintReader.parseUnsignedVarintLength(scratch[0]); + if (varintLength != C.LENGTH_UNSET && varintLength <= MAX_ID_BYTES) { + int potentialId = (int) VarintReader.assembleVarint(scratch, varintLength, false); + if (processor.isLevel1Element(potentialId)) { + input.skipFully(varintLength); + return potentialId; + } + } + input.skipFully(1); + } + } + + /** + * Reads and returns an integer of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the integer being read. + * @return The read integer value. + * @throws IOException If an error occurs reading from the input. + */ + private long readInteger(ExtractorInput input, int byteLength) throws IOException { + input.readFully(scratch, 0, byteLength); + long value = 0; + for (int i = 0; i < byteLength; i++) { + value = (value << 8) | (scratch[i] & 0xFF); + } + return value; + } + + /** + * Reads and returns a float of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the float being read. + * @return The read float value. + * @throws IOException If an error occurs reading from the input. + */ + private double readFloat(ExtractorInput input, int byteLength) throws IOException { + long integerValue = readInteger(input, byteLength); + double floatValue; + if (byteLength == VALID_FLOAT32_ELEMENT_SIZE_BYTES) { + floatValue = Float.intBitsToFloat((int) integerValue); + } else { + floatValue = Double.longBitsToDouble(integerValue); + } + return floatValue; + } + + /** + * Reads a string of length {@code byteLength} from the {@link ExtractorInput}. Zero padding is + * removed, so the returned string may be shorter than {@code byteLength}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the string being read, including zero padding. + * @return The read string value. + * @throws IOException If an error occurs reading from the input. + */ + private static String readString(ExtractorInput input, int byteLength) throws IOException { + if (byteLength == 0) { + return ""; + } + byte[] stringBytes = new byte[byteLength]; + input.readFully(stringBytes, 0, byteLength); + // Remove zero padding. + int trimmedLength = byteLength; + while (trimmedLength > 0 && stringBytes[trimmedLength - 1] == 0) { + trimmedLength--; + } + return new String(stringBytes, 0, trimmedLength); + } + + /** + * Used in {@link #masterElementsStack} to track when the current master element ends, so that + * {@link EbmlProcessor#endMasterElement(int)} can be called. + */ + private static final class MasterElement { + + private final int elementId; + private final long elementEndPosition; + + private MasterElement(int elementId, long elementEndPosition) { + this.elementId = elementId; + this.elementEndPosition = elementEndPosition; + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/EbmlProcessor.java b/android/app/src/main/java/tv/orbit/classic/mkv/EbmlProcessor.java new file mode 100644 index 0000000..313a2af --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/EbmlProcessor.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +import static java.lang.annotation.ElementType.TYPE_USE; + +import androidx.annotation.IntDef; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Defines EBML element IDs/types and processes events. */ +@UnstableApi +public interface EbmlProcessor { + + /** + * EBML element types. One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} or + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({ + ELEMENT_TYPE_UNKNOWN, + ELEMENT_TYPE_MASTER, + ELEMENT_TYPE_UNSIGNED_INT, + ELEMENT_TYPE_STRING, + ELEMENT_TYPE_BINARY, + ELEMENT_TYPE_FLOAT + }) + @interface ElementType {} + + /** Type for unknown elements. */ + int ELEMENT_TYPE_UNKNOWN = 0; + + /** Type for elements that contain child elements. */ + int ELEMENT_TYPE_MASTER = 1; + + /** Type for integer value elements of up to 8 bytes. */ + int ELEMENT_TYPE_UNSIGNED_INT = 2; + + /** Type for string elements. */ + int ELEMENT_TYPE_STRING = 3; + + /** Type for binary elements. */ + int ELEMENT_TYPE_BINARY = 4; + + /** Type for IEEE floating point value elements of either 4 or 8 bytes. */ + int ELEMENT_TYPE_FLOAT = 5; + + /** + * Maps an element ID to a corresponding type. + * + *

If {@link #ELEMENT_TYPE_UNKNOWN} is returned then the element is skipped. Note that all + * children of a skipped element are also skipped. + * + * @param id The element ID to map. + * @return One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} and + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @ElementType + int getElementType(int id); + + /** + * Checks if the given id is that of a level 1 element. + * + * @param id The element ID. + * @return Whether the given id is that of a level 1 element. + */ + boolean isLevel1Element(int id); + + /** + * Called when the start of a master element is encountered. + * + *

Following events should be considered as taking place within this element until a matching + * call to {@link #endMasterElement(int)} is made. + * + *

Note that it is possible for another master element of the same element ID to be nested + * within itself. + * + * @param id The element ID. + * @param contentPosition The position of the start of the element's content in the stream. + * @param contentSize The size of the element's content in bytes. + * @throws ParserException If a parsing error occurs. + */ + void startMasterElement(int id, long contentPosition, long contentSize) throws ParserException; + + /** + * Called when the end of a master element is encountered. + * + * @param id The element ID. + * @throws ParserException If a parsing error occurs. + */ + void endMasterElement(int id) throws ParserException; + + /** + * Called when an integer element is encountered. + * + * @param id The element ID. + * @param value The integer value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void integerElement(int id, long value) throws ParserException; + + /** + * Called when a float element is encountered. + * + * @param id The element ID. + * @param value The float value that the element contains + * @throws ParserException If a parsing error occurs. + */ + void floatElement(int id, double value) throws ParserException; + + /** + * Called when a string element is encountered. + * + * @param id The element ID. + * @param value The string value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void stringElement(int id, String value) throws ParserException; + + /** + * Called when a binary element is encountered. + * + *

The element header (containing the element ID and content size) will already have been read. + * Implementations are required to consume the whole remainder of the element, which is {@code + * contentSize} bytes in length, before returning. Implementations are permitted to fail (by + * throwing an exception) having partially consumed the data, however if they do this, they must + * consume the remainder of the content when called again. + * + * @param id The element ID. + * @param contentsSize The element's content size. + * @param input The {@link ExtractorInput} from which data should be read. + * @throws ParserException If a parsing error occurs. + * @throws IOException If an error occurs reading from the input. + */ + void binaryElement(int id, int contentsSize, ExtractorInput input) throws IOException; +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/EbmlReader.java b/android/app/src/main/java/tv/orbit/classic/mkv/EbmlReader.java new file mode 100644 index 0000000..59aa257 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/EbmlReader.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +import androidx.media3.common.ParserException; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; + +/** + * Event-driven EBML reader that delivers events to an {@link EbmlProcessor}. + * + *

EBML can be summarized as a binary XML format somewhat similar to Protocol Buffers. It was + * originally designed for the Matroska container format. More information about EBML and Matroska + * is available here. + */ +/* package */ interface EbmlReader { + + /** + * Initializes the extractor with an {@link EbmlProcessor}. + * + * @param processor An {@link EbmlProcessor} to process events. + */ + void init(EbmlProcessor processor); + + /** + * Resets the state of the reader. + * + *

Subsequent calls to {@link #read(ExtractorInput)} will start reading a new EBML structure + * from scratch. + */ + void reset(); + + /** + * Reads from an {@link ExtractorInput}, invoking an event callback if possible. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @return True if data can continue to be read. False if the end of the input was encountered. + * @throws ParserException If parsing fails. + * @throws IOException If an error occurs reading from the input. + */ + boolean read(ExtractorInput input) throws IOException; +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/MatroskaExtractor.java b/android/app/src/main/java/tv/orbit/classic/mkv/MatroskaExtractor.java new file mode 100644 index 0000000..57c2182 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/MatroskaExtractor.java @@ -0,0 +1,2679 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +// Orbit TV (4 November 2026): a copy of Media3 1.8.0's MatroskaExtractor, unchanged apart from this package and +// parseFourCcPrivate, which also recognises H.264 carried in the old Video for Windows wrapper (V_MS/VFW/FOURCC with an +// H264, X264, AVC1 or similar FourCC), as some older MKV rips do. Media3 maps those to video/x-unknown, so they play with +// sound and no picture. Everything else is Media3's own code, under its Apache 2.0 licence above. + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.common.util.Assertions.checkStateNotNull; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.util.Pair; +import android.util.SparseArray; +import androidx.annotation.CallSuper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.ColorInfo; +import androidx.media3.common.DrmInitData; +import androidx.media3.common.DrmInitData.SchemeData; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.LongArray; +import androidx.media3.common.util.NullableType; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.container.DolbyVisionConfig; +import androidx.media3.container.NalUnitUtil; +import androidx.media3.extractor.AacUtil; +import androidx.media3.extractor.AvcConfig; +import androidx.media3.extractor.ChunkIndex; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.HevcConfig; +import androidx.media3.extractor.MpegAudioUtil; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.TrackOutput; +import androidx.media3.extractor.TrueHdSampleRechunker; +import androidx.media3.extractor.text.SubtitleParser; +import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Extracts data from the Matroska and WebM container formats. */ +@UnstableApi +public class MatroskaExtractor implements Extractor { + + /** + * Creates a factory for {@link MatroskaExtractor} instances with the provided {@link + * SubtitleParser.Factory}. + */ + public static ExtractorsFactory newFactory(SubtitleParser.Factory subtitleParserFactory) { + return () -> new Extractor[] {new MatroskaExtractor(subtitleParserFactory)}; + } + + /** + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_DISABLE_SEEK_FOR_CUES} and {#FLAG_EMIT_RAW_SUBTITLE_DATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_SEEK_FOR_CUES, FLAG_EMIT_RAW_SUBTITLE_DATA}) + public @interface Flags {} + + /** + * Flag to disable seeking for cues. + * + *

Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its + * position is specified in the seek head and if it's after the first cluster. Setting this flag + * disables seeking to the cues element. If the cues element is after the first cluster then the + * media is treated as being unseekable. + */ + public static final int FLAG_DISABLE_SEEK_FOR_CUES = 1; + + /** + * Flag to use the source subtitle formats without modification. If unset, subtitles will be + * transcoded to {@link MimeTypes#APPLICATION_MEDIA3_CUES} during extraction. + */ + public static final int FLAG_EMIT_RAW_SUBTITLE_DATA = 1 << 1; // 2 + + /** + * @deprecated Use {@link #newFactory(SubtitleParser.Factory)} instead. + */ + @Deprecated + public static final ExtractorsFactory FACTORY = + () -> + new Extractor[] { + new MatroskaExtractor(SubtitleParser.Factory.UNSUPPORTED, FLAG_EMIT_RAW_SUBTITLE_DATA) + }; + + private static final String TAG = "MatroskaExtractor"; + + private static final int UNSET_ENTRY_ID = -1; + + private static final int BLOCK_STATE_START = 0; + private static final int BLOCK_STATE_HEADER = 1; + private static final int BLOCK_STATE_DATA = 2; + + private static final String DOC_TYPE_MATROSKA = "matroska"; + private static final String DOC_TYPE_WEBM = "webm"; + private static final String CODEC_ID_VP8 = "V_VP8"; + private static final String CODEC_ID_VP9 = "V_VP9"; + private static final String CODEC_ID_AV1 = "V_AV1"; + private static final String CODEC_ID_MPEG2 = "V_MPEG2"; + private static final String CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP"; + private static final String CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP"; + private static final String CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP"; + private static final String CODEC_ID_H264 = "V_MPEG4/ISO/AVC"; + private static final String CODEC_ID_H265 = "V_MPEGH/ISO/HEVC"; + private static final String CODEC_ID_FOURCC = "V_MS/VFW/FOURCC"; + private static final String CODEC_ID_THEORA = "V_THEORA"; + private static final String CODEC_ID_VORBIS = "A_VORBIS"; + private static final String CODEC_ID_OPUS = "A_OPUS"; + private static final String CODEC_ID_AAC = "A_AAC"; + private static final String CODEC_ID_MP2 = "A_MPEG/L2"; + private static final String CODEC_ID_MP3 = "A_MPEG/L3"; + private static final String CODEC_ID_AC3 = "A_AC3"; + private static final String CODEC_ID_E_AC3 = "A_EAC3"; + private static final String CODEC_ID_TRUEHD = "A_TRUEHD"; + private static final String CODEC_ID_DTS = "A_DTS"; + private static final String CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS"; + private static final String CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS"; + private static final String CODEC_ID_FLAC = "A_FLAC"; + private static final String CODEC_ID_ACM = "A_MS/ACM"; + private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; + private static final String CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG"; + private static final String CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE"; + private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; + private static final String CODEC_ID_ASS = "S_TEXT/ASS"; + private static final String CODEC_ID_SSA = "S_TEXT/SSA"; + private static final String CODEC_ID_VTT = "S_TEXT/WEBVTT"; + private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; + private static final String CODEC_ID_PGS = "S_HDMV/PGS"; + private static final String CODEC_ID_DVBSUB = "S_DVBSUB"; + + private static final int VORBIS_MAX_INPUT_SIZE = 8192; + private static final int OPUS_MAX_INPUT_SIZE = 5760; + private static final int ENCRYPTION_IV_SIZE = 8; + private static final int TRACK_TYPE_AUDIO = 2; + + private static final int ID_EBML = 0x1A45DFA3; + private static final int ID_EBML_READ_VERSION = 0x42F7; + private static final int ID_DOC_TYPE = 0x4282; + private static final int ID_DOC_TYPE_READ_VERSION = 0x4285; + private static final int ID_SEGMENT = 0x18538067; + private static final int ID_SEGMENT_INFO = 0x1549A966; + private static final int ID_SEEK_HEAD = 0x114D9B74; + private static final int ID_SEEK = 0x4DBB; + private static final int ID_SEEK_ID = 0x53AB; + private static final int ID_SEEK_POSITION = 0x53AC; + private static final int ID_INFO = 0x1549A966; + private static final int ID_TIMECODE_SCALE = 0x2AD7B1; + private static final int ID_DURATION = 0x4489; + private static final int ID_CLUSTER = 0x1F43B675; + private static final int ID_TIME_CODE = 0xE7; + private static final int ID_SIMPLE_BLOCK = 0xA3; + private static final int ID_BLOCK_GROUP = 0xA0; + private static final int ID_BLOCK = 0xA1; + private static final int ID_BLOCK_DURATION = 0x9B; + private static final int ID_BLOCK_ADDITIONS = 0x75A1; + private static final int ID_BLOCK_MORE = 0xA6; + private static final int ID_BLOCK_ADD_ID = 0xEE; + private static final int ID_BLOCK_ADDITIONAL = 0xA5; + private static final int ID_REFERENCE_BLOCK = 0xFB; + private static final int ID_TRACKS = 0x1654AE6B; + private static final int ID_TRACK_ENTRY = 0xAE; + private static final int ID_TRACK_NUMBER = 0xD7; + private static final int ID_TRACK_TYPE = 0x83; + private static final int ID_FLAG_DEFAULT = 0x88; + private static final int ID_FLAG_FORCED = 0x55AA; + private static final int ID_DEFAULT_DURATION = 0x23E383; + private static final int ID_MAX_BLOCK_ADDITION_ID = 0x55EE; + private static final int ID_BLOCK_ADDITION_MAPPING = 0x41E4; + private static final int ID_BLOCK_ADD_ID_TYPE = 0x41E7; + private static final int ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED; + private static final int ID_NAME = 0x536E; + private static final int ID_CODEC_ID = 0x86; + private static final int ID_CODEC_PRIVATE = 0x63A2; + private static final int ID_CODEC_DELAY = 0x56AA; + private static final int ID_SEEK_PRE_ROLL = 0x56BB; + private static final int ID_DISCARD_PADDING = 0x75A2; + private static final int ID_VIDEO = 0xE0; + private static final int ID_PIXEL_WIDTH = 0xB0; + private static final int ID_PIXEL_HEIGHT = 0xBA; + private static final int ID_DISPLAY_WIDTH = 0x54B0; + private static final int ID_DISPLAY_HEIGHT = 0x54BA; + private static final int ID_DISPLAY_UNIT = 0x54B2; + private static final int ID_AUDIO = 0xE1; + private static final int ID_CHANNELS = 0x9F; + private static final int ID_AUDIO_BIT_DEPTH = 0x6264; + private static final int ID_SAMPLING_FREQUENCY = 0xB5; + private static final int ID_CONTENT_ENCODINGS = 0x6D80; + private static final int ID_CONTENT_ENCODING = 0x6240; + private static final int ID_CONTENT_ENCODING_ORDER = 0x5031; + private static final int ID_CONTENT_ENCODING_SCOPE = 0x5032; + private static final int ID_CONTENT_COMPRESSION = 0x5034; + private static final int ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254; + private static final int ID_CONTENT_COMPRESSION_SETTINGS = 0x4255; + private static final int ID_CONTENT_ENCRYPTION = 0x5035; + private static final int ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1; + private static final int ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8; + private static final int ID_CUES = 0x1C53BB6B; + private static final int ID_CUE_POINT = 0xBB; + private static final int ID_CUE_TIME = 0xB3; + private static final int ID_CUE_TRACK_POSITIONS = 0xB7; + private static final int ID_CUE_CLUSTER_POSITION = 0xF1; + private static final int ID_LANGUAGE = 0x22B59C; + private static final int ID_PROJECTION = 0x7670; + private static final int ID_PROJECTION_TYPE = 0x7671; + private static final int ID_PROJECTION_PRIVATE = 0x7672; + private static final int ID_PROJECTION_POSE_YAW = 0x7673; + private static final int ID_PROJECTION_POSE_PITCH = 0x7674; + private static final int ID_PROJECTION_POSE_ROLL = 0x7675; + private static final int ID_STEREO_MODE = 0x53B8; + private static final int ID_COLOUR = 0x55B0; + private static final int ID_COLOUR_RANGE = 0x55B9; + private static final int ID_COLOUR_BITS_PER_CHANNEL = 0x55B2; + private static final int ID_COLOUR_TRANSFER = 0x55BA; + private static final int ID_COLOUR_PRIMARIES = 0x55BB; + private static final int ID_MAX_CLL = 0x55BC; + private static final int ID_MAX_FALL = 0x55BD; + private static final int ID_MASTERING_METADATA = 0x55D0; + private static final int ID_PRIMARY_R_CHROMATICITY_X = 0x55D1; + private static final int ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2; + private static final int ID_PRIMARY_G_CHROMATICITY_X = 0x55D3; + private static final int ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4; + private static final int ID_PRIMARY_B_CHROMATICITY_X = 0x55D5; + private static final int ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6; + private static final int ID_WHITE_POINT_CHROMATICITY_X = 0x55D7; + private static final int ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8; + private static final int ID_LUMNINANCE_MAX = 0x55D9; + private static final int ID_LUMNINANCE_MIN = 0x55DA; + + /** + * BlockAddID value for ITU T.35 metadata in a VP9 track. See also + * https://www.webmproject.org/docs/container/. + */ + private static final int BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4; + + /** + * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVCC = 0x64766343; + + /** + * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVVC = 0x64767643; + + private static final int LACING_NONE = 0; + private static final int LACING_XIPH = 1; + private static final int LACING_FIXED_SIZE = 2; + private static final int LACING_EBML = 3; + + private static final int FOURCC_COMPRESSION_DIVX = 0x58564944; + private static final int FOURCC_COMPRESSION_H263 = 0x33363248; + private static final int FOURCC_COMPRESSION_VC1 = 0x31435657; + /** H.264 FourCCs seen in the Video for Windows wrapper, upper and lower case (Orbit TV addition). */ + private static final java.util.Set FOURCC_COMPRESSION_H264 = new java.util.HashSet<>(java.util.Arrays.asList( + 0x34363248L /* H264 */, 0x34363268L /* h264 */, 0x34363258L /* X264 */, 0x34363278L /* x264 */, + 0x31435641L /* AVC1 */, 0x31637661L /* avc1 */, 0x43564144L /* DAVC */, 0x48535356L /* VSSH */)); + + /** Whether a Video for Windows compression FourCC (as read, little-endian) is H.264 (Orbit TV addition). */ + public static boolean h264FourCc(long compression) { + return FOURCC_COMPRESSION_H264.contains(compression); + } + + /** + * A template for the prefix that must be added to each subrip sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". + */ + private static final byte[] SUBRIP_PREFIX = + new byte[] { + 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, + 48, 58, 48, 48, 44, 48, 48, 48, 10 + }; + + /** The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. */ + private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). + */ + private static final long SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + + /** The format of a subrip timecode. */ + private static final String SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d"; + + /** Matroska specific format line for SSA subtitles. */ + private static final byte[] SSA_DIALOGUE_FORMAT = + Util.getUtf8Bytes( + "Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); + + /** + * A template for the prefix that must be added to each SSA sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private static final byte[] SSA_PREFIX = + new byte[] { + 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 + }; + + /** The byte offset of the end timecode in {@link #SSA_PREFIX}. */ + private static final int SSA_PREFIX_END_TIMECODE_OFFSET = 21; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10_000; + + /** The format of an SSA timecode. */ + private static final String SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d"; + + /** + * A template for the prefix that must be added to each VTT sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #VTT_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\n". + */ + private static final byte[] VTT_PREFIX = + new byte[] { + 87, 69, 66, 86, 84, 84, 10, 10, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 32, 45, 45, + 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 10 + }; + + /** The byte offset of the end timecode in {@link #VTT_PREFIX}. */ + private static final int VTT_PREFIX_END_TIMECODE_OFFSET = 25; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a VTT timecode (milliseconds). + */ + private static final long VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + + /** The format of a VTT timecode. */ + private static final String VTT_TIMECODE_FORMAT = "%02d:%02d:%02d.%03d"; + + /** The length in bytes of a WAVEFORMATEX structure. */ + private static final int WAVE_FORMAT_SIZE = 18; + + /** Format tag indicating a WAVEFORMATEXTENSIBLE structure. */ + private static final int WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + + /** Format tag for PCM. */ + private static final int WAVE_FORMAT_PCM = 1; + + /** Sub format for PCM. */ + private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L); + + /** Some HTC devices signal rotation in track names. */ + private static final Map TRACK_NAME_TO_ROTATION_DEGREES; + + static { + Map trackNameToRotationDegrees = new HashMap<>(); + trackNameToRotationDegrees.put("htc_video_rotA-000", 0); + trackNameToRotationDegrees.put("htc_video_rotA-090", 90); + trackNameToRotationDegrees.put("htc_video_rotA-180", 180); + trackNameToRotationDegrees.put("htc_video_rotA-270", 270); + TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees); + } + + private final EbmlReader reader; + private final VarintReader varintReader; + private final SparseArray tracks; + private final boolean seekForCuesEnabled; + private final boolean parseSubtitlesDuringExtraction; + private final SubtitleParser.Factory subtitleParserFactory; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private final ParsableByteArray scratch; + private final ParsableByteArray vorbisNumPageSamples; + private final ParsableByteArray seekEntryIdBytes; + private final ParsableByteArray sampleStrippedBytes; + private final ParsableByteArray subtitleSample; + private final ParsableByteArray encryptionInitializationVector; + private final ParsableByteArray encryptionSubsampleData; + private final ParsableByteArray supplementalData; + private ByteBuffer encryptionSubsampleDataBuffer; + + private long segmentContentSize; + private long segmentContentPosition = C.INDEX_UNSET; + private long timecodeScale = C.TIME_UNSET; + private long durationTimecode = C.TIME_UNSET; + private long durationUs = C.TIME_UNSET; + private boolean isWebm; + + // The track corresponding to the current TrackEntry element, or null. + @Nullable private Track currentTrack; + + // Whether a seek map has been sent to the output. + private boolean sentSeekMap; + + // Master seek entry related elements. + private int seekEntryId; + private long seekEntryPosition; + + // Cue related elements. + private boolean seekForCues; + private long cuesContentPosition = C.INDEX_UNSET; + private long seekPositionAfterBuildingCues = C.INDEX_UNSET; + private long clusterTimecodeUs = C.TIME_UNSET; + @Nullable private LongArray cueTimesUs; + @Nullable private LongArray cueClusterPositions; + private boolean seenClusterPositionForCurrentCuePoint; + + // Reading state. + private boolean haveOutputSample; + + // Block reading state. + private int blockState; + private long blockTimeUs; + private long blockDurationUs; + private int blockSampleIndex; + private int blockSampleCount; + private int[] blockSampleSizes; + private int blockTrackNumber; + private int blockTrackNumberLength; + private @C.BufferFlags int blockFlags; + private int blockAdditionalId; + private boolean blockHasReferenceBlock; + private long blockGroupDiscardPaddingNs; + + // Sample writing state. + private int sampleBytesRead; + private int sampleBytesWritten; + private int sampleCurrentNalBytesRemaining; + private boolean sampleEncodingHandled; + private boolean sampleSignalByteRead; + private boolean samplePartitionCountRead; + private int samplePartitionCount; + private byte sampleSignalByte; + private boolean sampleInitializationVectorRead; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + + /** + * @deprecated Use {@link #MatroskaExtractor(SubtitleParser.Factory)} instead. + */ + @Deprecated + public MatroskaExtractor() { + this(new DefaultEbmlReader(), FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED); + } + + /** + * @deprecated Use {@link #MatroskaExtractor(SubtitleParser.Factory, int)} instead. + */ + @Deprecated + public MatroskaExtractor(@Flags int flags) { + this( + new DefaultEbmlReader(), + flags | FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED); + } + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + */ + public MatroskaExtractor(SubtitleParser.Factory subtitleParserFactory) { + this(new DefaultEbmlReader(), /* flags= */ 0, subtitleParserFactory); + } + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + * @param flags Flags that control the extractor's behavior. + */ + public MatroskaExtractor(SubtitleParser.Factory subtitleParserFactory, @Flags int flags) { + this(new DefaultEbmlReader(), flags, subtitleParserFactory); + } + + /* package */ MatroskaExtractor( + EbmlReader reader, @Flags int flags, SubtitleParser.Factory subtitleParserFactory) { + this.reader = reader; + this.reader.init(new InnerEbmlProcessor()); + this.subtitleParserFactory = subtitleParserFactory; + seekForCuesEnabled = (flags & FLAG_DISABLE_SEEK_FOR_CUES) == 0; + parseSubtitlesDuringExtraction = (flags & FLAG_EMIT_RAW_SUBTITLE_DATA) == 0; + varintReader = new VarintReader(); + tracks = new SparseArray<>(); + scratch = new ParsableByteArray(4); + vorbisNumPageSamples = new ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()); + seekEntryIdBytes = new ParsableByteArray(4); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + sampleStrippedBytes = new ParsableByteArray(); + subtitleSample = new ParsableByteArray(); + encryptionInitializationVector = new ParsableByteArray(ENCRYPTION_IV_SIZE); + encryptionSubsampleData = new ParsableByteArray(); + supplementalData = new ParsableByteArray(); + blockSampleSizes = new int[1]; + } + + @Override + public final boolean sniff(ExtractorInput input) throws IOException { + return new Sniffer().sniff(input); + } + + @Override + public final void init(ExtractorOutput output) { + extractorOutput = + parseSubtitlesDuringExtraction + ? new SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) + : output; + } + + @CallSuper + @Override + public void seek(long position, long timeUs) { + clusterTimecodeUs = C.TIME_UNSET; + blockState = BLOCK_STATE_START; + reader.reset(); + varintReader.reset(); + resetWriteSampleData(); + for (int i = 0; i < tracks.size(); i++) { + tracks.valueAt(i).reset(); + } + } + + @Override + public final void release() { + // Do nothing + } + + @Override + public final int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + haveOutputSample = false; + boolean continueReading = true; + while (continueReading && !haveOutputSample) { + continueReading = reader.read(input); + if (continueReading && maybeSeekForCues(seekPosition, input.getPosition())) { + return Extractor.RESULT_SEEK; + } + } + if (!continueReading) { + for (int i = 0; i < tracks.size(); i++) { + Track track = tracks.valueAt(i); + track.assertOutputInitialized(); + track.outputPendingSampleMetadata(); + } + return Extractor.RESULT_END_OF_INPUT; + } + return Extractor.RESULT_CONTINUE; + } + + /** + * Maps an element ID to a corresponding type. + * + * @see EbmlProcessor#getElementType(int) + */ + @CallSuper + protected @EbmlProcessor.ElementType int getElementType(int id) { + switch (id) { + case ID_EBML: + case ID_SEGMENT: + case ID_SEEK_HEAD: + case ID_SEEK: + case ID_INFO: + case ID_CLUSTER: + case ID_TRACKS: + case ID_TRACK_ENTRY: + case ID_BLOCK_ADDITION_MAPPING: + case ID_AUDIO: + case ID_VIDEO: + case ID_CONTENT_ENCODINGS: + case ID_CONTENT_ENCODING: + case ID_CONTENT_COMPRESSION: + case ID_CONTENT_ENCRYPTION: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS: + case ID_CUES: + case ID_CUE_POINT: + case ID_CUE_TRACK_POSITIONS: + case ID_BLOCK_GROUP: + case ID_BLOCK_ADDITIONS: + case ID_BLOCK_MORE: + case ID_PROJECTION: + case ID_COLOUR: + case ID_MASTERING_METADATA: + return EbmlProcessor.ELEMENT_TYPE_MASTER; + case ID_EBML_READ_VERSION: + case ID_DOC_TYPE_READ_VERSION: + case ID_SEEK_POSITION: + case ID_TIMECODE_SCALE: + case ID_TIME_CODE: + case ID_BLOCK_DURATION: + case ID_PIXEL_WIDTH: + case ID_PIXEL_HEIGHT: + case ID_DISPLAY_WIDTH: + case ID_DISPLAY_HEIGHT: + case ID_DISPLAY_UNIT: + case ID_TRACK_NUMBER: + case ID_TRACK_TYPE: + case ID_FLAG_DEFAULT: + case ID_FLAG_FORCED: + case ID_DEFAULT_DURATION: + case ID_MAX_BLOCK_ADDITION_ID: + case ID_BLOCK_ADD_ID_TYPE: + case ID_CODEC_DELAY: + case ID_SEEK_PRE_ROLL: + case ID_DISCARD_PADDING: + case ID_CHANNELS: + case ID_AUDIO_BIT_DEPTH: + case ID_CONTENT_ENCODING_ORDER: + case ID_CONTENT_ENCODING_SCOPE: + case ID_CONTENT_COMPRESSION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + case ID_CUE_TIME: + case ID_CUE_CLUSTER_POSITION: + case ID_REFERENCE_BLOCK: + case ID_STEREO_MODE: + case ID_COLOUR_BITS_PER_CHANNEL: + case ID_COLOUR_RANGE: + case ID_COLOUR_TRANSFER: + case ID_COLOUR_PRIMARIES: + case ID_MAX_CLL: + case ID_MAX_FALL: + case ID_PROJECTION_TYPE: + case ID_BLOCK_ADD_ID: + return EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT; + case ID_DOC_TYPE: + case ID_NAME: + case ID_CODEC_ID: + case ID_LANGUAGE: + return EbmlProcessor.ELEMENT_TYPE_STRING; + case ID_SEEK_ID: + case ID_BLOCK_ADD_ID_EXTRA_DATA: + case ID_CONTENT_COMPRESSION_SETTINGS: + case ID_CONTENT_ENCRYPTION_KEY_ID: + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + case ID_CODEC_PRIVATE: + case ID_PROJECTION_PRIVATE: + case ID_BLOCK_ADDITIONAL: + return EbmlProcessor.ELEMENT_TYPE_BINARY; + case ID_DURATION: + case ID_SAMPLING_FREQUENCY: + case ID_PRIMARY_R_CHROMATICITY_X: + case ID_PRIMARY_R_CHROMATICITY_Y: + case ID_PRIMARY_G_CHROMATICITY_X: + case ID_PRIMARY_G_CHROMATICITY_Y: + case ID_PRIMARY_B_CHROMATICITY_X: + case ID_PRIMARY_B_CHROMATICITY_Y: + case ID_WHITE_POINT_CHROMATICITY_X: + case ID_WHITE_POINT_CHROMATICITY_Y: + case ID_LUMNINANCE_MAX: + case ID_LUMNINANCE_MIN: + case ID_PROJECTION_POSE_YAW: + case ID_PROJECTION_POSE_PITCH: + case ID_PROJECTION_POSE_ROLL: + return EbmlProcessor.ELEMENT_TYPE_FLOAT; + default: + return EbmlProcessor.ELEMENT_TYPE_UNKNOWN; + } + } + + /** + * Checks if the given id is that of a level 1 element. + * + * @see EbmlProcessor#isLevel1Element(int) + */ + @CallSuper + protected boolean isLevel1Element(int id) { + return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS; + } + + /** + * Called when the start of a master element is encountered. + * + * @see EbmlProcessor#startMasterElement(int, long, long) + */ + @CallSuper + protected void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + assertInitialized(); + switch (id) { + case ID_SEGMENT: + if (segmentContentPosition != C.INDEX_UNSET && segmentContentPosition != contentPosition) { + throw ParserException.createForMalformedContainer( + "Multiple Segment elements not supported", /* cause= */ null); + } + segmentContentPosition = contentPosition; + segmentContentSize = contentSize; + break; + case ID_SEEK: + seekEntryId = UNSET_ENTRY_ID; + seekEntryPosition = C.INDEX_UNSET; + break; + case ID_CUES: + cueTimesUs = new LongArray(); + cueClusterPositions = new LongArray(); + break; + case ID_CUE_POINT: + seenClusterPositionForCurrentCuePoint = false; + break; + case ID_CLUSTER: + if (!sentSeekMap) { + // We need to build cues before parsing the cluster. + if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET) { + // We know where the Cues element is located. Seek to request it. + seekForCues = true; + } else { + // We don't know where the Cues element is located. It's most likely omitted. Allow + // playback, but disable seeking. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + sentSeekMap = true; + } + } + break; + case ID_BLOCK_GROUP: + blockHasReferenceBlock = false; + blockGroupDiscardPaddingNs = 0L; + break; + case ID_CONTENT_ENCODING: + // TODO: check and fail if more than one content encoding is present. + break; + case ID_CONTENT_ENCRYPTION: + getCurrentTrack(id).hasContentEncryption = true; + break; + case ID_TRACK_ENTRY: + currentTrack = new Track(); + currentTrack.isWebm = isWebm; + break; + case ID_MASTERING_METADATA: + getCurrentTrack(id).hasColorInfo = true; + break; + default: + break; + } + } + + /** + * Called when the end of a master element is encountered. + * + * @see EbmlProcessor#endMasterElement(int) + */ + @CallSuper + protected void endMasterElement(int id) throws ParserException { + assertInitialized(); + switch (id) { + case ID_SEGMENT_INFO: + if (timecodeScale == C.TIME_UNSET) { + // timecodeScale was omitted. Use the default value. + timecodeScale = 1000000; + } + if (durationTimecode != C.TIME_UNSET) { + durationUs = scaleTimecodeToUs(durationTimecode); + } + break; + case ID_SEEK: + if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.INDEX_UNSET) { + throw ParserException.createForMalformedContainer( + "Mandatory element SeekID or SeekPosition not found", /* cause= */ null); + } + if (seekEntryId == ID_CUES) { + cuesContentPosition = seekEntryPosition; + } + break; + case ID_CUES: + if (!sentSeekMap) { + extractorOutput.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)); + sentSeekMap = true; + } else { + // We have already built the cues. Ignore. + } + this.cueTimesUs = null; + this.cueClusterPositions = null; + break; + case ID_BLOCK_GROUP: + if (blockState != BLOCK_STATE_DATA) { + // We've skipped this block (due to incompatible track number). + return; + } + Track track = tracks.get(blockTrackNumber); + track.assertOutputInitialized(); + if (blockGroupDiscardPaddingNs > 0L && CODEC_ID_OPUS.equals(track.codecId)) { + // For Opus, attach DiscardPadding to the block group samples as supplemental data. + supplementalData.reset( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(blockGroupDiscardPaddingNs) + .array()); + } + + // Commit sample metadata. + int sampleOffset = 0; + for (int i = 0; i < blockSampleCount; i++) { + sampleOffset += blockSampleSizes[i]; + } + for (int i = 0; i < blockSampleCount; i++) { + long sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000; + int sampleFlags = blockFlags; + if (i == 0 && !blockHasReferenceBlock) { + // If the ReferenceBlock element was not found in this block, then the first frame is a + // keyframe. + sampleFlags |= C.BUFFER_FLAG_KEY_FRAME; + } + int sampleSize = blockSampleSizes[i]; + sampleOffset -= sampleSize; // The offset is to the end of the sample. + commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset); + } + blockState = BLOCK_STATE_START; + break; + case ID_CONTENT_ENCODING: + assertInTrackEntry(id); + if (currentTrack.hasContentEncryption) { + if (currentTrack.cryptoData == null) { + throw ParserException.createForMalformedContainer( + "Encrypted Track found but ContentEncKeyID was not found", /* cause= */ null); + } + currentTrack.drmInitData = + new DrmInitData( + new SchemeData( + C.UUID_NIL, MimeTypes.VIDEO_WEBM, currentTrack.cryptoData.encryptionKey)); + } + break; + case ID_CONTENT_ENCODINGS: + assertInTrackEntry(id); + if (currentTrack.hasContentEncryption && currentTrack.sampleStrippedBytes != null) { + throw ParserException.createForMalformedContainer( + "Combining encryption and compression is not supported", /* cause= */ null); + } + break; + case ID_TRACK_ENTRY: + Track currentTrack = checkStateNotNull(this.currentTrack); + if (currentTrack.codecId == null) { + throw ParserException.createForMalformedContainer( + "CodecId is missing in TrackEntry element", /* cause= */ null); + } else { + if (isCodecSupported(currentTrack.codecId)) { + currentTrack.initializeOutput(extractorOutput, currentTrack.number); + tracks.put(currentTrack.number, currentTrack); + } + } + this.currentTrack = null; + break; + case ID_TRACKS: + if (tracks.size() == 0) { + throw ParserException.createForMalformedContainer( + "No valid tracks were found", /* cause= */ null); + } + extractorOutput.endTracks(); + break; + default: + break; + } + } + + /** + * Called when an integer element is encountered. + * + * @see EbmlProcessor#integerElement(int, long) + */ + @CallSuper + protected void integerElement(int id, long value) throws ParserException { + switch (id) { + case ID_EBML_READ_VERSION: + // Validate that EBMLReadVersion is supported. This extractor only supports v1. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "EBMLReadVersion " + value + " not supported", /* cause= */ null); + } + break; + case ID_DOC_TYPE_READ_VERSION: + // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. + if (value < 1 || value > 2) { + throw ParserException.createForMalformedContainer( + "DocTypeReadVersion " + value + " not supported", /* cause= */ null); + } + break; + case ID_SEEK_POSITION: + // Seek Position is the relative offset beginning from the Segment. So to get absolute + // offset from the beginning of the file, we need to add segmentContentPosition to it. + seekEntryPosition = value + segmentContentPosition; + break; + case ID_TIMECODE_SCALE: + timecodeScale = value; + break; + case ID_PIXEL_WIDTH: + getCurrentTrack(id).width = (int) value; + break; + case ID_PIXEL_HEIGHT: + getCurrentTrack(id).height = (int) value; + break; + case ID_DISPLAY_WIDTH: + getCurrentTrack(id).displayWidth = (int) value; + break; + case ID_DISPLAY_HEIGHT: + getCurrentTrack(id).displayHeight = (int) value; + break; + case ID_DISPLAY_UNIT: + getCurrentTrack(id).displayUnit = (int) value; + break; + case ID_TRACK_NUMBER: + getCurrentTrack(id).number = (int) value; + break; + case ID_FLAG_DEFAULT: + getCurrentTrack(id).flagDefault = value == 1; + break; + case ID_FLAG_FORCED: + getCurrentTrack(id).flagForced = value == 1; + break; + case ID_TRACK_TYPE: + getCurrentTrack(id).type = (int) value; + break; + case ID_DEFAULT_DURATION: + getCurrentTrack(id).defaultSampleDurationNs = (int) value; + break; + case ID_MAX_BLOCK_ADDITION_ID: + getCurrentTrack(id).maxBlockAdditionId = (int) value; + break; + case ID_BLOCK_ADD_ID_TYPE: + getCurrentTrack(id).blockAddIdType = (int) value; + break; + case ID_CODEC_DELAY: + getCurrentTrack(id).codecDelayNs = value; + break; + case ID_SEEK_PRE_ROLL: + getCurrentTrack(id).seekPreRollNs = value; + break; + case ID_DISCARD_PADDING: + blockGroupDiscardPaddingNs = value; + break; + case ID_CHANNELS: + getCurrentTrack(id).channelCount = (int) value; + break; + case ID_AUDIO_BIT_DEPTH: + getCurrentTrack(id).audioBitDepth = (int) value; + break; + case ID_REFERENCE_BLOCK: + blockHasReferenceBlock = true; + break; + case ID_CONTENT_ENCODING_ORDER: + // This extractor only supports one ContentEncoding element and hence the order has to be 0. + if (value != 0) { + throw ParserException.createForMalformedContainer( + "ContentEncodingOrder " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCODING_SCOPE: + // This extractor only supports the scope of all frames. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "ContentEncodingScope " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_COMPRESSION_ALGORITHM: + // This extractor only supports header stripping. + if (value != 3) { + throw ParserException.createForMalformedContainer( + "ContentCompAlgo " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCRYPTION_ALGORITHM: + // Only the value 5 (AES) is allowed according to the WebM specification. + if (value != 5) { + throw ParserException.createForMalformedContainer( + "ContentEncAlgo " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + // Only the value 1 is allowed according to the WebM specification. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "AESSettingsCipherMode " + value + " not supported", /* cause= */ null); + } + break; + case ID_CUE_TIME: + assertInCues(id); + cueTimesUs.add(scaleTimecodeToUs(value)); + break; + case ID_CUE_CLUSTER_POSITION: + if (!seenClusterPositionForCurrentCuePoint) { + assertInCues(id); + // If there's more than one video/audio track, then there could be more than one + // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first + // one (since the cluster position will be quite close for all the tracks). + cueClusterPositions.add(value); + seenClusterPositionForCurrentCuePoint = true; + } + break; + case ID_TIME_CODE: + clusterTimecodeUs = scaleTimecodeToUs(value); + break; + case ID_BLOCK_DURATION: + blockDurationUs = scaleTimecodeToUs(value); + break; + case ID_STEREO_MODE: + int layout = (int) value; + assertInTrackEntry(id); + switch (layout) { + case 0: + currentTrack.stereoMode = C.STEREO_MODE_MONO; + break; + case 1: + currentTrack.stereoMode = C.STEREO_MODE_LEFT_RIGHT; + break; + case 3: + currentTrack.stereoMode = C.STEREO_MODE_TOP_BOTTOM; + break; + case 15: + currentTrack.stereoMode = C.STEREO_MODE_STEREO_MESH; + break; + default: + break; + } + break; + case ID_COLOUR_PRIMARIES: + assertInTrackEntry(id); + currentTrack.hasColorInfo = true; + int colorSpace = ColorInfo.isoColorPrimariesToColorSpace((int) value); + if (colorSpace != Format.NO_VALUE) { + currentTrack.colorSpace = colorSpace; + } + break; + case ID_COLOUR_TRANSFER: + assertInTrackEntry(id); + int colorTransfer = ColorInfo.isoTransferCharacteristicsToColorTransfer((int) value); + if (colorTransfer != Format.NO_VALUE) { + currentTrack.colorTransfer = colorTransfer; + } + break; + case ID_COLOUR_BITS_PER_CHANNEL: + assertInTrackEntry(id); + currentTrack.hasColorInfo = true; + currentTrack.bitsPerChannel = (int) value; + break; + case ID_COLOUR_RANGE: + assertInTrackEntry(id); + switch ((int) value) { + case 1: // Broadcast range. + currentTrack.colorRange = C.COLOR_RANGE_LIMITED; + break; + case 2: + currentTrack.colorRange = C.COLOR_RANGE_FULL; + break; + default: + break; + } + break; + case ID_MAX_CLL: + getCurrentTrack(id).maxContentLuminance = (int) value; + break; + case ID_MAX_FALL: + getCurrentTrack(id).maxFrameAverageLuminance = (int) value; + break; + case ID_PROJECTION_TYPE: + assertInTrackEntry(id); + switch ((int) value) { + case 0: + currentTrack.projectionType = C.PROJECTION_RECTANGULAR; + break; + case 1: + currentTrack.projectionType = C.PROJECTION_EQUIRECTANGULAR; + break; + case 2: + currentTrack.projectionType = C.PROJECTION_CUBEMAP; + break; + case 3: + currentTrack.projectionType = C.PROJECTION_MESH; + break; + default: + break; + } + break; + case ID_BLOCK_ADD_ID: + blockAdditionalId = (int) value; + break; + default: + break; + } + } + + /** + * Called when a float element is encountered. + * + * @see EbmlProcessor#floatElement(int, double) + */ + @CallSuper + protected void floatElement(int id, double value) throws ParserException { + switch (id) { + case ID_DURATION: + durationTimecode = (long) value; + break; + case ID_SAMPLING_FREQUENCY: + getCurrentTrack(id).sampleRate = (int) value; + break; + case ID_PRIMARY_R_CHROMATICITY_X: + getCurrentTrack(id).primaryRChromaticityX = (float) value; + break; + case ID_PRIMARY_R_CHROMATICITY_Y: + getCurrentTrack(id).primaryRChromaticityY = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_X: + getCurrentTrack(id).primaryGChromaticityX = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_Y: + getCurrentTrack(id).primaryGChromaticityY = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_X: + getCurrentTrack(id).primaryBChromaticityX = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_Y: + getCurrentTrack(id).primaryBChromaticityY = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_X: + getCurrentTrack(id).whitePointChromaticityX = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_Y: + getCurrentTrack(id).whitePointChromaticityY = (float) value; + break; + case ID_LUMNINANCE_MAX: + getCurrentTrack(id).maxMasteringLuminance = (float) value; + break; + case ID_LUMNINANCE_MIN: + getCurrentTrack(id).minMasteringLuminance = (float) value; + break; + case ID_PROJECTION_POSE_YAW: + getCurrentTrack(id).projectionPoseYaw = (float) value; + break; + case ID_PROJECTION_POSE_PITCH: + getCurrentTrack(id).projectionPosePitch = (float) value; + break; + case ID_PROJECTION_POSE_ROLL: + getCurrentTrack(id).projectionPoseRoll = (float) value; + break; + default: + break; + } + } + + /** + * Called when a string element is encountered. + * + * @see EbmlProcessor#stringElement(int, String) + */ + @CallSuper + protected void stringElement(int id, String value) throws ParserException { + switch (id) { + case ID_DOC_TYPE: + // Validate that DocType is supported. + if (!DOC_TYPE_WEBM.equals(value) && !DOC_TYPE_MATROSKA.equals(value)) { + throw ParserException.createForMalformedContainer( + "DocType " + value + " not supported", /* cause= */ null); + } + isWebm = Objects.equals(value, DOC_TYPE_WEBM); + break; + case ID_NAME: + getCurrentTrack(id).name = value; + break; + case ID_CODEC_ID: + getCurrentTrack(id).codecId = value; + break; + case ID_LANGUAGE: + getCurrentTrack(id).language = value; + break; + default: + break; + } + } + + /** + * Called when a binary element is encountered. + * + * @see EbmlProcessor#binaryElement(int, int, ExtractorInput) + */ + @CallSuper + protected void binaryElement(int id, int contentSize, ExtractorInput input) throws IOException { + switch (id) { + case ID_SEEK_ID: + Arrays.fill(seekEntryIdBytes.getData(), (byte) 0); + input.readFully(seekEntryIdBytes.getData(), 4 - contentSize, contentSize); + seekEntryIdBytes.setPosition(0); + seekEntryId = (int) seekEntryIdBytes.readUnsignedInt(); + break; + case ID_BLOCK_ADD_ID_EXTRA_DATA: + handleBlockAddIDExtraData(getCurrentTrack(id), input, contentSize); + break; + case ID_CODEC_PRIVATE: + assertInTrackEntry(id); + currentTrack.codecPrivate = new byte[contentSize]; + input.readFully(currentTrack.codecPrivate, 0, contentSize); + break; + case ID_PROJECTION_PRIVATE: + assertInTrackEntry(id); + currentTrack.projectionData = new byte[contentSize]; + input.readFully(currentTrack.projectionData, 0, contentSize); + break; + case ID_CONTENT_COMPRESSION_SETTINGS: + assertInTrackEntry(id); + // This extractor only supports header stripping, so the payload is the stripped bytes. + currentTrack.sampleStrippedBytes = new byte[contentSize]; + input.readFully(currentTrack.sampleStrippedBytes, 0, contentSize); + break; + case ID_CONTENT_ENCRYPTION_KEY_ID: + byte[] encryptionKey = new byte[contentSize]; + input.readFully(encryptionKey, 0, contentSize); + getCurrentTrack(id).cryptoData = + new TrackOutput.CryptoData( + C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0); // We assume patternless AES-CTR. + break; + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure + // and http://matroska.org/technical/specs/index.html#block_structure + // for info about how data is organized in SimpleBlock and Block elements respectively. They + // differ only in the way flags are specified. + + if (blockState == BLOCK_STATE_START) { + blockTrackNumber = (int) varintReader.readUnsignedVarint(input, false, true, 8); + blockTrackNumberLength = varintReader.getLastLength(); + blockDurationUs = C.TIME_UNSET; + blockState = BLOCK_STATE_HEADER; + scratch.reset(/* limit= */ 0); + } + + Track track = tracks.get(blockTrackNumber); + + // Ignore the block if we don't know about the track to which it belongs. + if (track == null) { + input.skipFully(contentSize - blockTrackNumberLength); + blockState = BLOCK_STATE_START; + return; + } + + track.assertOutputInitialized(); + + if (blockState == BLOCK_STATE_HEADER) { + // Read the relative timecode (2 bytes) and flags (1 byte). + readScratch(input, 3); + int lacing = (scratch.getData()[2] & 0x06) >> 1; + if (lacing == LACING_NONE) { + blockSampleCount = 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1); + blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3; + } else { + // Read the sample count (1 byte). + readScratch(input, 4); + blockSampleCount = (scratch.getData()[3] & 0xFF) + 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount); + if (lacing == LACING_FIXED_SIZE) { + int blockLacingSampleSize = + (contentSize - blockTrackNumberLength - 4) / blockSampleCount; + Arrays.fill(blockSampleSizes, 0, blockSampleCount, blockLacingSampleSize); + } else if (lacing == LACING_XIPH) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + int byteValue; + do { + readScratch(input, ++headerSize); + byteValue = scratch.getData()[headerSize - 1] & 0xFF; + blockSampleSizes[sampleIndex] += byteValue; + } while (byteValue == 0xFF); + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else if (lacing == LACING_EBML) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + readScratch(input, ++headerSize); + if (scratch.getData()[headerSize - 1] == 0) { + throw ParserException.createForMalformedContainer( + "No valid varint length mask found", /* cause= */ null); + } + long readValue = 0; + for (int i = 0; i < 8; i++) { + int lengthMask = 1 << (7 - i); + if ((scratch.getData()[headerSize - 1] & lengthMask) != 0) { + int readPosition = headerSize - 1; + headerSize += i; + readScratch(input, headerSize); + readValue = (scratch.getData()[readPosition++] & 0xFF) & ~lengthMask; + while (readPosition < headerSize) { + readValue <<= 8; + readValue |= (scratch.getData()[readPosition++] & 0xFF); + } + // The first read value is the first size. Later values are signed offsets. + if (sampleIndex > 0) { + readValue -= (1L << (6 + i * 7)) - 1; + } + break; + } + } + if (readValue < Integer.MIN_VALUE || readValue > Integer.MAX_VALUE) { + throw ParserException.createForMalformedContainer( + "EBML lacing sample size out of range.", /* cause= */ null); + } + int intReadValue = (int) readValue; + blockSampleSizes[sampleIndex] = + sampleIndex == 0 + ? intReadValue + : blockSampleSizes[sampleIndex - 1] + intReadValue; + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else { + // Lacing is always in the range 0--3. + throw ParserException.createForMalformedContainer( + "Unexpected lacing value: " + lacing, /* cause= */ null); + } + } + + int timecode = (scratch.getData()[0] << 8) | (scratch.getData()[1] & 0xFF); + blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode); + boolean isKeyframe = + track.type == TRACK_TYPE_AUDIO + || (id == ID_SIMPLE_BLOCK && (scratch.getData()[2] & 0x80) == 0x80); + blockFlags = isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + blockState = BLOCK_STATE_DATA; + blockSampleIndex = 0; + } + + if (id == ID_SIMPLE_BLOCK) { + // For SimpleBlock, we can write sample data and immediately commit the corresponding + // sample metadata. + while (blockSampleIndex < blockSampleCount) { + int sampleSize = + writeSampleData( + input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ false); + long sampleTimeUs = + blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000; + commitSampleToOutput(track, sampleTimeUs, blockFlags, sampleSize, /* offset= */ 0); + blockSampleIndex++; + } + blockState = BLOCK_STATE_START; + } else { + // For Block, we need to wait until the end of the BlockGroup element before committing + // sample metadata. This is so that we can handle ReferenceBlock (which can be used to + // infer whether the first sample in the block is a keyframe), and BlockAdditions (which + // can contain additional sample data to append) contained in the block group. Just output + // the sample data, storing the final sample sizes for when we commit the metadata. + while (blockSampleIndex < blockSampleCount) { + blockSampleSizes[blockSampleIndex] = + writeSampleData( + input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ true); + blockSampleIndex++; + } + } + + break; + case ID_BLOCK_ADDITIONAL: + if (blockState != BLOCK_STATE_DATA) { + return; + } + handleBlockAdditionalData( + tracks.get(blockTrackNumber), blockAdditionalId, input, contentSize); + break; + default: + throw ParserException.createForMalformedContainer( + "Unexpected id: " + id, /* cause= */ null); + } + } + + protected void handleBlockAddIDExtraData(Track track, ExtractorInput input, int contentSize) + throws IOException { + if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC + || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC) { + track.dolbyVisionConfigBytes = new byte[contentSize]; + input.readFully(track.dolbyVisionConfigBytes, 0, contentSize); + } else { + // Unhandled BlockAddIDExtraData. + input.skipFully(contentSize); + } + } + + protected void handleBlockAdditionalData( + Track track, int blockAdditionalId, ExtractorInput input, int contentSize) + throws IOException { + if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 + && CODEC_ID_VP9.equals(track.codecId)) { + supplementalData.reset(contentSize); + input.readFully(supplementalData.getData(), 0, contentSize); + } else { + // Unhandled block additional data. + input.skipFully(contentSize); + } + } + + private void assertInTrackEntry(int id) throws ParserException { + if (currentTrack == null) { + throw ParserException.createForMalformedContainer( + "Element " + id + " must be in a TrackEntry", /* cause= */ null); + } + } + + private void assertInCues(int id) throws ParserException { + if (cueTimesUs == null || cueClusterPositions == null) { + throw ParserException.createForMalformedContainer( + "Element " + id + " must be in a Cues", /* cause= */ null); + } + } + + /** + * Returns the track corresponding to the current TrackEntry element. + * + * @throws ParserException if the element id is not in a TrackEntry. + */ + protected Track getCurrentTrack(int currentElementId) throws ParserException { + assertInTrackEntry(currentElementId); + return currentTrack; + } + + private void commitSampleToOutput( + Track track, long timeUs, @C.BufferFlags int flags, int size, int offset) { + if (track.trueHdSampleRechunker != null) { + track.trueHdSampleRechunker.sampleMetadata( + track.output, timeUs, flags, size, offset, track.cryptoData); + } else { + if (CODEC_ID_SUBRIP.equals(track.codecId) + || CODEC_ID_ASS.equals(track.codecId) + || CODEC_ID_SSA.equals(track.codecId) + || CODEC_ID_VTT.equals(track.codecId)) { + if (blockSampleCount > 1) { + Log.w(TAG, "Skipping subtitle sample in laced block."); + } else if (blockDurationUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping subtitle sample with no duration."); + } else { + setSubtitleEndTime(track.codecId, blockDurationUs, subtitleSample.getData()); + // The Matroska spec doesn't clearly define whether subtitle samples are null-terminated + // or the sample should instead be sized precisely. We truncate the sample at a null-byte + // to gracefully handle null-terminated strings followed by garbage bytes. + for (int i = subtitleSample.getPosition(); i < subtitleSample.limit(); i++) { + if (subtitleSample.getData()[i] == 0) { + subtitleSample.setLimit(i); + break; + } + } + // Note: If we ever want to support DRM protected subtitles then we'll need to output the + // appropriate encryption data here. + track.output.sampleData(subtitleSample, subtitleSample.limit()); + size += subtitleSample.limit(); + } + } + + if ((flags & C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) { + if (blockSampleCount > 1) { + // There were multiple samples in the block. Appending the additional data to the last + // sample doesn't make sense. Skip instead. + supplementalData.reset(/* limit= */ 0); + } else { + // Append supplemental data. + int supplementalDataSize = supplementalData.limit(); + track.output.sampleData( + supplementalData, supplementalDataSize, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); + size += supplementalDataSize; + } + } + track.output.sampleMetadata(timeUs, flags, size, offset, track.cryptoData); + } + haveOutputSample = true; + } + + /** + * Ensures {@link #scratch} contains at least {@code requiredLength} bytes of data, reading from + * the extractor input if necessary. + */ + private void readScratch(ExtractorInput input, int requiredLength) throws IOException { + if (scratch.limit() >= requiredLength) { + return; + } + if (scratch.capacity() < requiredLength) { + scratch.ensureCapacity(max(scratch.capacity() * 2, requiredLength)); + } + input.readFully(scratch.getData(), scratch.limit(), requiredLength - scratch.limit()); + scratch.setLimit(requiredLength); + } + + /** + * Writes data for a single sample to the track output. + * + * @param input The input from which to read sample data. + * @param track The track to output the sample to. + * @param size The size of the sample data on the input side. + * @param isBlockGroup Whether the samples are from a BlockGroup. + * @return The final size of the written sample. + * @throws IOException If an error occurs reading from the input. + */ + private int writeSampleData(ExtractorInput input, Track track, int size, boolean isBlockGroup) + throws IOException { + if (CODEC_ID_SUBRIP.equals(track.codecId)) { + writeSubtitleSampleData(input, SUBRIP_PREFIX, size); + return finishWriteSampleData(); + } else if (CODEC_ID_ASS.equals(track.codecId) || CODEC_ID_SSA.equals(track.codecId)) { + writeSubtitleSampleData(input, SSA_PREFIX, size); + return finishWriteSampleData(); + } else if (CODEC_ID_VTT.equals(track.codecId)) { + writeSubtitleSampleData(input, VTT_PREFIX, size); + return finishWriteSampleData(); + } + + TrackOutput output = track.output; + if (!sampleEncodingHandled) { + if (track.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags &= ~C.BUFFER_FLAG_ENCRYPTED; + if (!sampleSignalByteRead) { + input.readFully(scratch.getData(), 0, 1); + sampleBytesRead++; + if ((scratch.getData()[0] & 0x80) == 0x80) { + throw ParserException.createForMalformedContainer( + "Extension bit is set in signal byte", /* cause= */ null); + } + sampleSignalByte = scratch.getData()[0]; + sampleSignalByteRead = true; + } + boolean isEncrypted = (sampleSignalByte & 0x01) == 0x01; + if (isEncrypted) { + boolean hasSubsampleEncryption = (sampleSignalByte & 0x02) == 0x02; + blockFlags |= C.BUFFER_FLAG_ENCRYPTED; + if (!sampleInitializationVectorRead) { + input.readFully(encryptionInitializationVector.getData(), 0, ENCRYPTION_IV_SIZE); + sampleBytesRead += ENCRYPTION_IV_SIZE; + sampleInitializationVectorRead = true; + // Write the signal byte, containing the IV size and the subsample encryption flag. + scratch.getData()[0] = + (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00)); + scratch.setPosition(0); + output.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten++; + // Write the IV. + encryptionInitializationVector.setPosition(0); + output.sampleData( + encryptionInitializationVector, + ENCRYPTION_IV_SIZE, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten += ENCRYPTION_IV_SIZE; + } + if (hasSubsampleEncryption) { + if (!samplePartitionCountRead) { + input.readFully(scratch.getData(), 0, 1); + sampleBytesRead++; + scratch.setPosition(0); + samplePartitionCount = scratch.readUnsignedByte(); + samplePartitionCountRead = true; + } + int samplePartitionDataSize = samplePartitionCount * 4; + scratch.reset(samplePartitionDataSize); + input.readFully(scratch.getData(), 0, samplePartitionDataSize); + sampleBytesRead += samplePartitionDataSize; + short subsampleCount = (short) (1 + (samplePartitionCount / 2)); + int subsampleDataSize = 2 + 6 * subsampleCount; + if (encryptionSubsampleDataBuffer == null + || encryptionSubsampleDataBuffer.capacity() < subsampleDataSize) { + encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize); + } + encryptionSubsampleDataBuffer.position(0); + encryptionSubsampleDataBuffer.putShort(subsampleCount); + // Loop through the partition offsets and write out the data in the way ExoPlayer + // wants it (ISO 23001-7 Part 7): + // 2 bytes - sub sample count. + // for each sub sample: + // 2 bytes - clear data size. + // 4 bytes - encrypted data size. + int partitionOffset = 0; + for (int i = 0; i < samplePartitionCount; i++) { + int previousPartitionOffset = partitionOffset; + partitionOffset = scratch.readUnsignedIntToInt(); + if ((i % 2) == 0) { + encryptionSubsampleDataBuffer.putShort( + (short) (partitionOffset - previousPartitionOffset)); + } else { + encryptionSubsampleDataBuffer.putInt(partitionOffset - previousPartitionOffset); + } + } + int finalPartitionSize = size - sampleBytesRead - partitionOffset; + if ((samplePartitionCount % 2) == 1) { + encryptionSubsampleDataBuffer.putInt(finalPartitionSize); + } else { + encryptionSubsampleDataBuffer.putShort((short) finalPartitionSize); + encryptionSubsampleDataBuffer.putInt(0); + } + encryptionSubsampleData.reset(encryptionSubsampleDataBuffer.array(), subsampleDataSize); + output.sampleData( + encryptionSubsampleData, + subsampleDataSize, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten += subsampleDataSize; + } + } + } else if (track.sampleStrippedBytes != null) { + // If the sample has header stripping, prepare to read/output the stripped bytes first. + sampleStrippedBytes.reset(track.sampleStrippedBytes, track.sampleStrippedBytes.length); + } + + if (track.samplesHaveSupplementalData(isBlockGroup)) { + blockFlags |= C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA; + supplementalData.reset(/* limit= */ 0); + // If there is supplemental data, the structure of the sample data is: + // encryption data (if any) || sample size (4 bytes) || sample data || supplemental data + int sampleSize = size + sampleStrippedBytes.limit() - sampleBytesRead; + scratch.reset(/* limit= */ 4); + scratch.getData()[0] = (byte) ((sampleSize >> 24) & 0xFF); + scratch.getData()[1] = (byte) ((sampleSize >> 16) & 0xFF); + scratch.getData()[2] = (byte) ((sampleSize >> 8) & 0xFF); + scratch.getData()[3] = (byte) (sampleSize & 0xFF); + output.sampleData(scratch, 4, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); + sampleBytesWritten += 4; + } + + sampleEncodingHandled = true; + } + size += sampleStrippedBytes.limit(); + + if (CODEC_ID_H264.equals(track.codecId) || CODEC_ID_H265.equals(track.codecId)) { + // TODO: Deduplicate with Mp4Extractor. + + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.getData(); + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLength = track.nalUnitLengthFieldLength; + int nalUnitLengthFieldLengthDiff = 4 - track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesRead < size) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one. + writeToTarget( + input, nalLengthData, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + sampleBytesRead += nalUnitLengthFieldLength; + nalLength.setPosition(0); + sampleCurrentNalBytesRemaining = nalLength.readUnsignedIntToInt(); + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + sampleBytesWritten += 4; + } else { + // Write the payload of the NAL unit. + int bytesWritten = writeToOutput(input, output, sampleCurrentNalBytesRemaining); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + sampleCurrentNalBytesRemaining -= bytesWritten; + } + } + } else { + if (track.trueHdSampleRechunker != null) { + checkState(sampleStrippedBytes.limit() == 0); + track.trueHdSampleRechunker.startSample(input); + } + while (sampleBytesRead < size) { + int bytesWritten = writeToOutput(input, output, size - sampleBytesRead); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + } + } + + if (CODEC_ID_VORBIS.equals(track.codecId)) { + // Vorbis decoder in android MediaCodec [1] expects the last 4 bytes of the sample to be the + // number of samples in the current page. This definition holds good only for Ogg and + // irrelevant for Matroska. So we always set this to -1 (the decoder will ignore this value if + // we set it to -1). The android platform media extractor [2] does the same. + // [1] + // https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/codecs/vorbis/dec/SoftVorbis.cpp#314 + // [2] + // https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/NuMediaExtractor.cpp#474 + vorbisNumPageSamples.setPosition(0); + output.sampleData(vorbisNumPageSamples, 4); + sampleBytesWritten += 4; + } + + return finishWriteSampleData(); + } + + /** + * Called by {@link #writeSampleData(ExtractorInput, Track, int, boolean)} when the sample has + * been written. Returns the final sample size and resets state for the next sample. + */ + private int finishWriteSampleData() { + int sampleSize = sampleBytesWritten; + resetWriteSampleData(); + return sampleSize; + } + + /** Resets state used by {@link #writeSampleData(ExtractorInput, Track, int, boolean)}. */ + private void resetWriteSampleData() { + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + sampleEncodingHandled = false; + sampleSignalByteRead = false; + samplePartitionCountRead = false; + samplePartitionCount = 0; + sampleSignalByte = (byte) 0; + sampleInitializationVectorRead = false; + sampleStrippedBytes.reset(/* limit= */ 0); + } + + private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) + throws IOException { + int sizeWithPrefix = samplePrefix.length + size; + if (subtitleSample.capacity() < sizeWithPrefix) { + // Initialize subripSample to contain the required prefix and have space to hold a subtitle + // twice as long as this one. + subtitleSample.reset(Arrays.copyOf(samplePrefix, sizeWithPrefix + size)); + } else { + System.arraycopy(samplePrefix, 0, subtitleSample.getData(), 0, samplePrefix.length); + } + input.readFully(subtitleSample.getData(), samplePrefix.length, size); + subtitleSample.setPosition(0); + subtitleSample.setLimit(sizeWithPrefix); + // Defer writing the data to the track output. We need to modify the sample data by setting + // the correct end timecode, which we might not have yet. + } + + /** + * Overwrites the end timecode in {@code subtitleData} with the correctly formatted time derived + * from {@code durationUs}. + * + *

See documentation on {@link #SSA_DIALOGUE_FORMAT} and {@link #SUBRIP_PREFIX} for why we use + * the duration as the end timecode. + * + * @param codecId The subtitle codec; must be {@link #CODEC_ID_SUBRIP}, {@link #CODEC_ID_ASS}, + * {@link #CODEC_ID_SSA} or {@link #CODEC_ID_VTT}. + * @param durationUs The duration of the sample, in microseconds. + * @param subtitleData The subtitle sample in which to overwrite the end timecode (output + * parameter). + */ + private static void setSubtitleEndTime(String codecId, long durationUs, byte[] subtitleData) { + byte[] endTimecode; + int endTimecodeOffset; + switch (codecId) { + case CODEC_ID_SUBRIP: + endTimecode = + formatSubtitleTimecode( + durationUs, SUBRIP_TIMECODE_FORMAT, SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET; + break; + case CODEC_ID_ASS: + case CODEC_ID_SSA: + endTimecode = + formatSubtitleTimecode( + durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET; + break; + case CODEC_ID_VTT: + endTimecode = + formatSubtitleTimecode( + durationUs, VTT_TIMECODE_FORMAT, VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = VTT_PREFIX_END_TIMECODE_OFFSET; + break; + default: + throw new IllegalArgumentException(); + } + System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.length); + } + + /** + * Formats {@code timeUs} using {@code timecodeFormat}, and sets it as the end timecode in {@code + * subtitleSampleData}. + */ + private static byte[] formatSubtitleTimecode( + long timeUs, String timecodeFormat, long lastTimecodeValueScalingFactor) { + checkArgument(timeUs != C.TIME_UNSET); + byte[] timeCodeData; + int hours = (int) (timeUs / (3600 * C.MICROS_PER_SECOND)); + timeUs -= (hours * 3600L * C.MICROS_PER_SECOND); + int minutes = (int) (timeUs / (60 * C.MICROS_PER_SECOND)); + timeUs -= (minutes * 60L * C.MICROS_PER_SECOND); + int seconds = (int) (timeUs / C.MICROS_PER_SECOND); + timeUs -= (seconds * C.MICROS_PER_SECOND); + int lastValue = (int) (timeUs / lastTimecodeValueScalingFactor); + timeCodeData = + Util.getUtf8Bytes( + String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue)); + return timeCodeData; + } + + /** + * Writes {@code length} bytes of sample data into {@code target} at {@code offset}, consisting of + * pending {@link #sampleStrippedBytes} and any remaining data read from {@code input}. + */ + private void writeToTarget(ExtractorInput input, byte[] target, int offset, int length) + throws IOException { + int pendingStrippedBytes = min(length, sampleStrippedBytes.bytesLeft()); + input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); + if (pendingStrippedBytes > 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); + } + } + + /** + * Outputs up to {@code length} bytes of sample data to {@code output}, consisting of either + * {@link #sampleStrippedBytes} or data read from {@code input}. + */ + private int writeToOutput(ExtractorInput input, TrackOutput output, int length) + throws IOException { + int bytesWritten; + int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); + if (strippedBytesLeft > 0) { + bytesWritten = min(length, strippedBytesLeft); + output.sampleData(sampleStrippedBytes, bytesWritten); + } else { + bytesWritten = output.sampleData(input, length, false); + } + return bytesWritten; + } + + /** + * Builds a {@link SeekMap} from the recently gathered Cues information. + * + * @return The built {@link SeekMap}. The returned {@link SeekMap} may be unseekable if cues + * information was missing or incomplete. + */ + private SeekMap buildSeekMap( + @Nullable LongArray cueTimesUs, @Nullable LongArray cueClusterPositions) { + if (segmentContentPosition == C.INDEX_UNSET + || durationUs == C.TIME_UNSET + || cueTimesUs == null + || cueTimesUs.size() == 0 + || cueClusterPositions == null + || cueClusterPositions.size() != cueTimesUs.size()) { + // Cues information is missing or incomplete. + return new SeekMap.Unseekable(durationUs); + } + int cuePointsSize = cueTimesUs.size(); + int[] sizes = new int[cuePointsSize]; + long[] offsets = new long[cuePointsSize]; + long[] durationsUs = new long[cuePointsSize]; + long[] timesUs = new long[cuePointsSize]; + for (int i = 0; i < cuePointsSize; i++) { + timesUs[i] = cueTimesUs.get(i); + offsets[i] = segmentContentPosition + cueClusterPositions.get(i); + } + for (int i = 0; i < cuePointsSize - 1; i++) { + sizes[i] = (int) (offsets[i + 1] - offsets[i]); + durationsUs[i] = timesUs[i + 1] - timesUs[i]; + } + + // Start from the last cue point and move backward until a valid duration is found. + int lastValidIndex = cuePointsSize - 1; + while (lastValidIndex > 0 && timesUs[lastValidIndex] > durationUs) { + lastValidIndex--; + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (int) (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]); + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex]; + + // If the last valid index is not the last cue point, truncate the arrays + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration"); + sizes = Arrays.copyOf(sizes, lastValidIndex + 1); + offsets = Arrays.copyOf(offsets, lastValidIndex + 1); + durationsUs = Arrays.copyOf(durationsUs, lastValidIndex + 1); + timesUs = Arrays.copyOf(timesUs, lastValidIndex + 1); + } + + return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + } + + /** + * Updates the position of the holder to Cues element's position if the extractor configuration + * permits use of master seek entry. After building Cues sets the holder's position back to where + * it was before. + * + * @param seekPosition The holder whose position will be updated. + * @param currentPosition Current position of the input. + * @return Whether the seek position was updated. + */ + private boolean maybeSeekForCues(PositionHolder seekPosition, long currentPosition) { + if (seekForCues) { + seekPositionAfterBuildingCues = currentPosition; + seekPosition.position = cuesContentPosition; + seekForCues = false; + return true; + } + // After parsing Cues, seek back to original position if available. We will not do this unless + // we seeked to get to the Cues in the first place. + if (sentSeekMap && seekPositionAfterBuildingCues != C.INDEX_UNSET) { + seekPosition.position = seekPositionAfterBuildingCues; + seekPositionAfterBuildingCues = C.INDEX_UNSET; + return true; + } + return false; + } + + private long scaleTimecodeToUs(long unscaledTimecode) throws ParserException { + if (timecodeScale == C.TIME_UNSET) { + throw ParserException.createForMalformedContainer( + "Can't scale timecode prior to timecodeScale being set.", /* cause= */ null); + } + return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000); + } + + private static boolean isCodecSupported(String codecId) { + switch (codecId) { + case CODEC_ID_VP8: + case CODEC_ID_VP9: + case CODEC_ID_AV1: + case CODEC_ID_MPEG2: + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + case CODEC_ID_H264: + case CODEC_ID_H265: + case CODEC_ID_FOURCC: + case CODEC_ID_THEORA: + case CODEC_ID_OPUS: + case CODEC_ID_VORBIS: + case CODEC_ID_AAC: + case CODEC_ID_MP2: + case CODEC_ID_MP3: + case CODEC_ID_AC3: + case CODEC_ID_E_AC3: + case CODEC_ID_TRUEHD: + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + case CODEC_ID_DTS_LOSSLESS: + case CODEC_ID_FLAC: + case CODEC_ID_ACM: + case CODEC_ID_PCM_INT_LIT: + case CODEC_ID_PCM_INT_BIG: + case CODEC_ID_PCM_FLOAT: + case CODEC_ID_SUBRIP: + case CODEC_ID_ASS: + case CODEC_ID_SSA: + case CODEC_ID_VTT: + case CODEC_ID_VOBSUB: + case CODEC_ID_PGS: + case CODEC_ID_DVBSUB: + return true; + default: + return false; + } + } + + /** + * Returns an array that can store (at least) {@code length} elements, which will be either a new + * array or {@code array} if it's not null and large enough. + */ + private static int[] ensureArrayCapacity(@Nullable int[] array, int length) { + if (array == null) { + return new int[length]; + } else if (array.length >= length) { + return array; + } else { + // Double the size to avoid allocating constantly if the required length increases gradually. + return new int[max(array.length * 2, length)]; + } + } + + private void assertInitialized() { + checkStateNotNull(extractorOutput); + } + + /** Passes events through to the outer {@link MatroskaExtractor}. */ + private final class InnerEbmlProcessor implements EbmlProcessor { + + @Override + public @ElementType int getElementType(int id) { + return MatroskaExtractor.this.getElementType(id); + } + + @Override + public boolean isLevel1Element(int id) { + return MatroskaExtractor.this.isLevel1Element(id); + } + + @Override + public void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + MatroskaExtractor.this.startMasterElement(id, contentPosition, contentSize); + } + + @Override + public void endMasterElement(int id) throws ParserException { + MatroskaExtractor.this.endMasterElement(id); + } + + @Override + public void integerElement(int id, long value) throws ParserException { + MatroskaExtractor.this.integerElement(id, value); + } + + @Override + public void floatElement(int id, double value) throws ParserException { + MatroskaExtractor.this.floatElement(id, value); + } + + @Override + public void stringElement(int id, String value) throws ParserException { + MatroskaExtractor.this.stringElement(id, value); + } + + @Override + public void binaryElement(int id, int contentsSize, ExtractorInput input) throws IOException { + MatroskaExtractor.this.binaryElement(id, contentsSize, input); + } + } + + /** Holds data corresponding to a single track. */ + protected static final class Track { + + private static final int DISPLAY_UNIT_PIXELS = 0; + private static final int MAX_CHROMATICITY = 50_000; // Defined in CTA-861.3. + + /** Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ + private static final int DEFAULT_MAX_CLL = 1000; // nits. + + /** Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. */ + private static final int DEFAULT_MAX_FALL = 200; // nits. + + // Common elements. + public boolean isWebm; + public String name; + public String codecId; + public int number; + public int type; + public int defaultSampleDurationNs; + public int maxBlockAdditionId; + private int blockAddIdType; + public boolean hasContentEncryption; + public byte [] sampleStrippedBytes; + public TrackOutput.CryptoData cryptoData; + public byte [] codecPrivate; + public DrmInitData drmInitData; + + // Video elements. + public int width = Format.NO_VALUE; + public int height = Format.NO_VALUE; + public int bitsPerChannel = Format.NO_VALUE; + public int displayWidth = Format.NO_VALUE; + public int displayHeight = Format.NO_VALUE; + public int displayUnit = DISPLAY_UNIT_PIXELS; + public @C.Projection int projectionType = Format.NO_VALUE; + public float projectionPoseYaw = 0f; + public float projectionPosePitch = 0f; + public float projectionPoseRoll = 0f; + public byte [] projectionData = null; + public @C.StereoMode int stereoMode = Format.NO_VALUE; + public boolean hasColorInfo = false; + public @C.ColorSpace int colorSpace = Format.NO_VALUE; + public @C.ColorTransfer int colorTransfer = Format.NO_VALUE; + public @C.ColorRange int colorRange = Format.NO_VALUE; + public int maxContentLuminance = DEFAULT_MAX_CLL; + public int maxFrameAverageLuminance = DEFAULT_MAX_FALL; + public float primaryRChromaticityX = Format.NO_VALUE; + public float primaryRChromaticityY = Format.NO_VALUE; + public float primaryGChromaticityX = Format.NO_VALUE; + public float primaryGChromaticityY = Format.NO_VALUE; + public float primaryBChromaticityX = Format.NO_VALUE; + public float primaryBChromaticityY = Format.NO_VALUE; + public float whitePointChromaticityX = Format.NO_VALUE; + public float whitePointChromaticityY = Format.NO_VALUE; + public float maxMasteringLuminance = Format.NO_VALUE; + public float minMasteringLuminance = Format.NO_VALUE; + public byte [] dolbyVisionConfigBytes; + + // Audio elements. Initially set to their default values. + public int channelCount = 1; + public int audioBitDepth = Format.NO_VALUE; + public int sampleRate = 8000; + public long codecDelayNs = 0; + public long seekPreRollNs = 0; + public TrueHdSampleRechunker trueHdSampleRechunker; + + // Text elements. + public boolean flagForced; + public boolean flagDefault = true; + private String language = "eng"; + + // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. + public TrackOutput output; + public int nalUnitLengthFieldLength; + + /** Initializes the track with an output. */ + public void initializeOutput(ExtractorOutput output, int trackId) throws ParserException { + String mimeType; + int maxInputSize = Format.NO_VALUE; + @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; + @Nullable List initializationData = null; + @Nullable String codecs = null; + switch (codecId) { + case CODEC_ID_VP8: + mimeType = MimeTypes.VIDEO_VP8; + break; + case CODEC_ID_VP9: + mimeType = MimeTypes.VIDEO_VP9; + initializationData = codecPrivate == null ? null : ImmutableList.of(codecPrivate); + break; + case CODEC_ID_AV1: + mimeType = MimeTypes.VIDEO_AV1; + initializationData = codecPrivate == null ? null : ImmutableList.of(codecPrivate); + break; + case CODEC_ID_MPEG2: + mimeType = MimeTypes.VIDEO_MPEG2; + break; + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + mimeType = MimeTypes.VIDEO_MP4V; + initializationData = + codecPrivate == null ? null : Collections.singletonList(codecPrivate); + break; + case CODEC_ID_H264: + mimeType = MimeTypes.VIDEO_H264; + AvcConfig avcConfig = AvcConfig.parse(new ParsableByteArray(getCodecPrivate(codecId))); + initializationData = avcConfig.initializationData; + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + codecs = avcConfig.codecs; + break; + case CODEC_ID_H265: + mimeType = MimeTypes.VIDEO_H265; + HevcConfig hevcConfig = HevcConfig.parse(new ParsableByteArray(getCodecPrivate(codecId))); + initializationData = hevcConfig.initializationData; + nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength; + codecs = hevcConfig.codecs; + break; + case CODEC_ID_FOURCC: + Pair> pair = + parseFourCcPrivate(new ParsableByteArray(getCodecPrivate(codecId))); + mimeType = pair.first; + initializationData = pair.second; + break; + case CODEC_ID_THEORA: + // TODO: This can be set to the real mimeType if/when we work out what initializationData + // should be set to for this case. + mimeType = MimeTypes.VIDEO_UNKNOWN; + break; + case CODEC_ID_VORBIS: + mimeType = MimeTypes.AUDIO_VORBIS; + maxInputSize = VORBIS_MAX_INPUT_SIZE; + initializationData = parseVorbisCodecPrivate(getCodecPrivate(codecId)); + break; + case CODEC_ID_OPUS: + mimeType = MimeTypes.AUDIO_OPUS; + maxInputSize = OPUS_MAX_INPUT_SIZE; + initializationData = new ArrayList<>(3); + initializationData.add(getCodecPrivate(codecId)); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs).array()); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs).array()); + break; + case CODEC_ID_AAC: + mimeType = MimeTypes.AUDIO_AAC; + initializationData = Collections.singletonList(getCodecPrivate(codecId)); + AacUtil.Config aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate); + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See [Internal: b/10903778]. + sampleRate = aacConfig.sampleRateHz; + channelCount = aacConfig.channelCount; + codecs = aacConfig.codecs; + break; + case CODEC_ID_MP2: + mimeType = MimeTypes.AUDIO_MPEG_L2; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_MP3: + mimeType = MimeTypes.AUDIO_MPEG; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_AC3: + mimeType = MimeTypes.AUDIO_AC3; + break; + case CODEC_ID_E_AC3: + mimeType = MimeTypes.AUDIO_E_AC3; + break; + case CODEC_ID_TRUEHD: + mimeType = MimeTypes.AUDIO_TRUEHD; + trueHdSampleRechunker = new TrueHdSampleRechunker(); + break; + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + mimeType = MimeTypes.AUDIO_DTS; + break; + case CODEC_ID_DTS_LOSSLESS: + mimeType = MimeTypes.AUDIO_DTS_HD; + break; + case CODEC_ID_FLAC: + mimeType = MimeTypes.AUDIO_FLAC; + initializationData = Collections.singletonList(getCodecPrivate(codecId)); + break; + case CODEC_ID_ACM: + mimeType = MimeTypes.AUDIO_RAW; + if (parseMsAcmCodecPrivate(new ParsableByteArray(getCodecPrivate(codecId)))) { + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + } else { + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Non-PCM MS/ACM is unsupported. Setting mimeType to " + mimeType); + } + break; + case CODEC_ID_PCM_INT_LIT: + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported little endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_INT_BIG: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 8) { + pcmEncoding = C.ENCODING_PCM_8BIT; + } else if (audioBitDepth == 16) { + pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; + } else if (audioBitDepth == 24) { + pcmEncoding = C.ENCODING_PCM_24BIT_BIG_ENDIAN; + } else if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_32BIT_BIG_ENDIAN; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported big endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_FLOAT: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_FLOAT; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported floating point PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_SUBRIP: + mimeType = MimeTypes.APPLICATION_SUBRIP; + break; + case CODEC_ID_ASS: + case CODEC_ID_SSA: + mimeType = MimeTypes.TEXT_SSA; + initializationData = ImmutableList.of(SSA_DIALOGUE_FORMAT, getCodecPrivate(codecId)); + break; + case CODEC_ID_VTT: + mimeType = MimeTypes.TEXT_VTT; + break; + case CODEC_ID_VOBSUB: + mimeType = MimeTypes.APPLICATION_VOBSUB; + initializationData = ImmutableList.of(getCodecPrivate(codecId)); + break; + case CODEC_ID_PGS: + mimeType = MimeTypes.APPLICATION_PGS; + break; + case CODEC_ID_DVBSUB: + mimeType = MimeTypes.APPLICATION_DVBSUBS; + // Init data: composition_page (2), ancillary_page (2) + byte[] initializationDataBytes = new byte[4]; + System.arraycopy(getCodecPrivate(codecId), 0, initializationDataBytes, 0, 4); + initializationData = ImmutableList.of(initializationDataBytes); + break; + default: + throw ParserException.createForMalformedContainer( + "Unrecognized codec identifier.", /* cause= */ null); + } + + if (dolbyVisionConfigBytes != null) { + @Nullable + DolbyVisionConfig dolbyVisionConfig = + DolbyVisionConfig.parse(new ParsableByteArray(this.dolbyVisionConfigBytes)); + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs; + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + } + } + + @C.SelectionFlags int selectionFlags = 0; + selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; + selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; + + int type; + Format.Builder formatBuilder = new Format.Builder(); + // TODO: Consider reading the name elements of the tracks and, if present, incorporating them + // into the trackId passed when creating the formats. + if (MimeTypes.isAudio(mimeType)) { + type = C.TRACK_TYPE_AUDIO; + formatBuilder + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding); + } else if (MimeTypes.isVideo(mimeType)) { + type = C.TRACK_TYPE_VIDEO; + if (displayUnit == Track.DISPLAY_UNIT_PIXELS) { + displayWidth = displayWidth == Format.NO_VALUE ? width : displayWidth; + displayHeight = displayHeight == Format.NO_VALUE ? height : displayHeight; + } + float pixelWidthHeightRatio = Format.NO_VALUE; + if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { + pixelWidthHeightRatio = ((float) (height * displayWidth)) / (width * displayHeight); + } + @Nullable ColorInfo colorInfo = null; + if (hasColorInfo) { + @Nullable byte[] hdrStaticInfo = getHdrStaticInfo(); + colorInfo = + new ColorInfo.Builder() + .setColorSpace(colorSpace) + .setColorRange(colorRange) + .setColorTransfer(colorTransfer) + .setHdrStaticInfo(hdrStaticInfo) + .setLumaBitdepth(bitsPerChannel) + .setChromaBitdepth(bitsPerChannel) + .build(); + } + int rotationDegrees = Format.NO_VALUE; + + if (name != null && TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES.get(name); + } + if (projectionType == C.PROJECTION_RECTANGULAR + && Float.compare(projectionPoseYaw, 0f) == 0 + && Float.compare(projectionPosePitch, 0f) == 0) { + // The range of projectionPoseRoll is [-180, 180]. + if (Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0; + } else if (Float.compare(projectionPoseRoll, 90f) == 0) { + rotationDegrees = 90; + } else if (Float.compare(projectionPoseRoll, -180f) == 0 + || Float.compare(projectionPoseRoll, 180f) == 0) { + rotationDegrees = 180; + } else if (Float.compare(projectionPoseRoll, -90f) == 0) { + rotationDegrees = 270; + } + } + formatBuilder + .setWidth(width) + .setHeight(height) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .setRotationDegrees(rotationDegrees) + .setProjectionData(projectionData) + .setStereoMode(stereoMode) + .setColorInfo(colorInfo); + } else if (MimeTypes.APPLICATION_SUBRIP.equals(mimeType) + || MimeTypes.TEXT_SSA.equals(mimeType) + || MimeTypes.TEXT_VTT.equals(mimeType) + || MimeTypes.APPLICATION_VOBSUB.equals(mimeType) + || MimeTypes.APPLICATION_PGS.equals(mimeType) + || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + } else { + throw ParserException.createForMalformedContainer( + "Unexpected MIME type.", /* cause= */ null); + } + + if (name != null && !TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + formatBuilder.setLabel(name); + } + + Format format = + formatBuilder + .setId(trackId) + .setContainerMimeType(isWebm ? MimeTypes.VIDEO_WEBM : MimeTypes.VIDEO_MATROSKA) + .setSampleMimeType(mimeType) + .setMaxInputSize(maxInputSize) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setInitializationData(initializationData) + .setCodecs(codecs) + .setDrmInitData(drmInitData) + .build(); + + this.output = output.track(number, type); + this.output.format(format); + } + + /** Forces any pending sample metadata to be flushed to the output. */ + public void outputPendingSampleMetadata() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.outputPendingSampleMetadata(output, cryptoData); + } + } + + /** Resets any state stored in the track in response to a seek. */ + public void reset() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.reset(); + } + } + + /** + * Returns true if supplemental data will be attached to the samples. + * + * @param isBlockGroup Whether the samples are from a BlockGroup. + */ + private boolean samplesHaveSupplementalData(boolean isBlockGroup) { + if (CODEC_ID_OPUS.equals(codecId)) { + // At the end of a BlockGroup, a positive DiscardPadding value will be written out as + // supplemental data for Opus codec. Otherwise (i.e. DiscardPadding <= 0) supplemental data + // size will be 0. + return isBlockGroup; + } + return maxBlockAdditionId > 0; + } + + /** Returns the HDR Static Info as defined in CTA-861.3. */ + @Nullable + private byte[] getHdrStaticInfo() { + // Are all fields present. + if (primaryRChromaticityX == Format.NO_VALUE + || primaryRChromaticityY == Format.NO_VALUE + || primaryGChromaticityX == Format.NO_VALUE + || primaryGChromaticityY == Format.NO_VALUE + || primaryBChromaticityX == Format.NO_VALUE + || primaryBChromaticityY == Format.NO_VALUE + || whitePointChromaticityX == Format.NO_VALUE + || whitePointChromaticityY == Format.NO_VALUE + || maxMasteringLuminance == Format.NO_VALUE + || minMasteringLuminance == Format.NO_VALUE) { + return null; + } + + byte[] hdrStaticInfoData = new byte[25]; + ByteBuffer hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN); + hdrStaticInfo.put((byte) 0); // Type. + hdrStaticInfo.putShort((short) ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) (maxMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) (minMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) maxContentLuminance); + hdrStaticInfo.putShort((short) maxFrameAverageLuminance); + return hdrStaticInfoData; + } + + /** + * Builds initialization data for a {@link Format} from FourCC codec private data. + * + * @return The codec MIME type and initialization data. If the compression type is not supported + * then the MIME type is set to {@link MimeTypes#VIDEO_UNKNOWN} and the initialization data + * is {@code null}. + * @throws ParserException If the initialization data could not be built. + */ + private static Pair> parseFourCcPrivate( + ParsableByteArray buffer) throws ParserException { + try { + buffer.skipBytes(16); // size(4), width(4), height(4), planes(2), bitcount(2). + long compression = buffer.readLittleEndianUnsignedInt(); + if (compression == FOURCC_COMPRESSION_DIVX) { + return new Pair<>(MimeTypes.VIDEO_DIVX, null); + } else if (compression == FOURCC_COMPRESSION_H263) { + return new Pair<>(MimeTypes.VIDEO_H263, null); + } else if (h264FourCc(compression)) { + // Annex B with its parameter sets in the stream, which the decoder reads without separate initialization data. + return new Pair<>(MimeTypes.VIDEO_H264, null); + } else if (compression == FOURCC_COMPRESSION_VC1) { + // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 + // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). + int startOffset = buffer.getPosition() + 20; + byte[] bufferData = buffer.getData(); + for (int offset = startOffset; offset < bufferData.length - 4; offset++) { + if (bufferData[offset] == 0x00 + && bufferData[offset + 1] == 0x00 + && bufferData[offset + 2] == 0x01 + && bufferData[offset + 3] == 0x0F) { + // We've found the initialization data. + byte[] initializationData = Arrays.copyOfRange(bufferData, offset, bufferData.length); + return new Pair<>(MimeTypes.VIDEO_VC1, Collections.singletonList(initializationData)); + } + } + throw ParserException.createForMalformedContainer( + "Failed to find FourCC VC1 initialization data", /* cause= */ null); + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing FourCC private data", /* cause= */ null); + } + + Log.w(TAG, "Unknown FourCC. Setting mimeType to " + MimeTypes.VIDEO_UNKNOWN); + return new Pair<>(MimeTypes.VIDEO_UNKNOWN, null); + } + + /** + * Builds initialization data for a {@link Format} from Vorbis codec private data. + * + * @return The initialization data for the {@link Format}. + * @throws ParserException If the initialization data could not be built. + */ + private static List parseVorbisCodecPrivate(byte[] codecPrivate) + throws ParserException { + try { + if (codecPrivate[0] != 0x02) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + int offset = 1; + int vorbisInfoLength = 0; + while ((codecPrivate[offset] & 0xFF) == 0xFF) { + vorbisInfoLength += 0xFF; + offset++; + } + vorbisInfoLength += codecPrivate[offset++] & 0xFF; + + int vorbisSkipLength = 0; + while ((codecPrivate[offset] & 0xFF) == 0xFF) { + vorbisSkipLength += 0xFF; + offset++; + } + vorbisSkipLength += codecPrivate[offset++] & 0xFF; + + if (codecPrivate[offset] != 0x01) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + byte[] vorbisInfo = new byte[vorbisInfoLength]; + System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength); + offset += vorbisInfoLength; + if (codecPrivate[offset] != 0x03) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + offset += vorbisSkipLength; + if (codecPrivate[offset] != 0x05) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + byte[] vorbisBooks = new byte[codecPrivate.length - offset]; + System.arraycopy(codecPrivate, offset, vorbisBooks, 0, codecPrivate.length - offset); + List initializationData = new ArrayList<>(2); + initializationData.add(vorbisInfo); + initializationData.add(vorbisBooks); + return initializationData; + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + } + + /** + * Parses an MS/ACM codec private, returning whether it indicates PCM audio. + * + * @return Whether the codec private indicates PCM audio. + * @throws ParserException If a parsing error occurs. + */ + private static boolean parseMsAcmCodecPrivate(ParsableByteArray buffer) throws ParserException { + try { + int formatTag = buffer.readLittleEndianUnsignedShort(); + if (formatTag == WAVE_FORMAT_PCM) { + return true; + } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { + buffer.setPosition(WAVE_FORMAT_SIZE + 6); // unionSamples(2), channelMask(4) + return buffer.readLong() == WAVE_SUBFORMAT_PCM.getMostSignificantBits() + && buffer.readLong() == WAVE_SUBFORMAT_PCM.getLeastSignificantBits(); + } else { + return false; + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing MS/ACM codec private", /* cause= */ null); + } + } + + /** + * Checks that the track has an output. + * + *

It is unfortunately not possible to mark {@link MatroskaExtractor#tracks} as only + * containing tracks with output with the nullness checker. This method is used to check that + * fact at runtime. + */ + private void assertOutputInitialized() { + checkNotNull(output); + } + + private byte[] getCodecPrivate(String codecId) throws ParserException { + if (codecPrivate == null) { + throw ParserException.createForMalformedContainer( + "Missing CodecPrivate for codec " + codecId, /* cause= */ null); + } + return codecPrivate; + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/Sniffer.java b/android/app/src/main/java/tv/orbit/classic/mkv/Sniffer.java new file mode 100644 index 0000000..7952893 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/Sniffer.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; + +/** + * Utility class that peeks from the input stream in order to determine whether it appears to be + * compatible input for this extractor. + */ +/* package */ final class Sniffer { + + /** The number of bytes to search for a valid header in {@link #sniff(ExtractorInput)}. */ + private static final int SEARCH_LENGTH = 1024; + + private static final int ID_EBML = 0x1A45DFA3; + + private final ParsableByteArray scratch; + private int peekLength; + + public Sniffer() { + scratch = new ParsableByteArray(8); + } + + /** See {@link Extractor#sniff(ExtractorInput)}. */ + public boolean sniff(ExtractorInput input) throws IOException { + long inputLength = input.getLength(); + int bytesToSearch = + (int) + (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH + ? SEARCH_LENGTH + : inputLength); + // Find four bytes equal to ID_EBML near the start of the input. + input.peekFully(scratch.getData(), 0, 4); + long tag = scratch.readUnsignedInt(); + peekLength = 4; + while (tag != ID_EBML) { + if (++peekLength == bytesToSearch) { + return false; + } + input.peekFully(scratch.getData(), 0, 1); + tag = (tag << 8) & 0xFFFFFF00; + tag |= scratch.getData()[0] & 0xFF; + } + + // Read the size of the EBML header and make sure it is within the stream. + long headerSize = readUint(input); + long headerStart = peekLength; + if (headerSize == Long.MIN_VALUE + || (inputLength != C.LENGTH_UNSET && headerStart + headerSize >= inputLength)) { + return false; + } + + // Read the payload elements in the EBML header. + while (peekLength < headerStart + headerSize) { + long id = readUint(input); + if (id == Long.MIN_VALUE) { + return false; + } + long size = readUint(input); + if (size < 0 || size > Integer.MAX_VALUE) { + return false; + } + if (size != 0) { + int sizeInt = (int) size; + input.advancePeekPosition(sizeInt); + peekLength += sizeInt; + } + } + return peekLength == headerStart + headerSize; + } + + /** Peeks a variable-length unsigned EBML integer from the input. */ + private long readUint(ExtractorInput input) throws IOException { + input.peekFully(scratch.getData(), 0, 1); + int value = scratch.getData()[0] & 0xFF; + if (value == 0) { + return Long.MIN_VALUE; + } + int mask = 0x80; + int length = 0; + while ((value & mask) == 0) { + mask >>= 1; + length++; + } + value &= ~mask; + input.peekFully(scratch.getData(), 1, length); + for (int i = 0; i < length; i++) { + value <<= 8; + value += scratch.getData()[i + 1] & 0xFF; + } + peekLength += length + 1; + return value; + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/mkv/VarintReader.java b/android/app/src/main/java/tv/orbit/classic/mkv/VarintReader.java new file mode 100644 index 0000000..24f97c9 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/mkv/VarintReader.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tv.orbit.classic.mkv; + +import androidx.media3.common.C; +import androidx.media3.extractor.ExtractorInput; +import java.io.EOFException; +import java.io.IOException; + +/** Reads EBML variable-length integers (varints) from an {@link ExtractorInput}. */ +/* package */ final class VarintReader { + + private static final int STATE_BEGIN_READING = 0; + private static final int STATE_READ_CONTENTS = 1; + + /** + * The first byte of a variable-length integer (varint) will have one of these bit masks + * indicating the total length in bytes. + * + *

{@code 0x80} is a one-byte integer, {@code 0x40} is two bytes, and so on up to eight bytes. + */ + private static final long[] VARINT_LENGTH_MASKS = + new long[] {0x80L, 0x40L, 0x20L, 0x10L, 0x08L, 0x04L, 0x02L, 0x01L}; + + private final byte[] scratch; + + private int state; + private int length; + + public VarintReader() { + scratch = new byte[8]; + } + + /** Resets the reader to start reading a new variable-length integer. */ + public void reset() { + state = STATE_BEGIN_READING; + length = 0; + } + + /** + * Reads an EBML variable-length integer (varint) from an {@link ExtractorInput} such that reading + * can be resumed later if an error occurs having read only some of it. + * + *

If an value is successfully read, then the reader will automatically reset itself ready to + * read another value. + * + *

If an {@link IOException} is thrown, the read can be resumed later by calling this method + * again, passing an {@link ExtractorInput} providing data starting where the previous one left + * off. + * + * @param input The {@link ExtractorInput} from which the integer should be read. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it + * should be considered an error, causing an {@link EOFException} to be thrown. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @param maximumAllowedLength Maximum allowed length of the variable integer to be read. + * @return The read value, or {@link C#RESULT_END_OF_INPUT} if {@code allowEndOfStream} is true + * and the end of the input was encountered, or {@link C#RESULT_MAX_LENGTH_EXCEEDED} if the + * length of the varint exceeded maximumAllowedLength. + * @throws IOException If an error occurs reading from the input. + */ + public long readUnsignedVarint( + ExtractorInput input, + boolean allowEndOfInput, + boolean removeLengthMask, + int maximumAllowedLength) + throws IOException { + if (state == STATE_BEGIN_READING) { + // Read the first byte to establish the length. + if (!input.readFully(scratch, 0, 1, allowEndOfInput)) { + return C.RESULT_END_OF_INPUT; + } + int firstByte = scratch[0] & 0xFF; + length = parseUnsignedVarintLength(firstByte); + if (length == C.LENGTH_UNSET) { + throw new IllegalStateException("No valid varint length mask found"); + } + state = STATE_READ_CONTENTS; + } + + if (length > maximumAllowedLength) { + state = STATE_BEGIN_READING; + return C.RESULT_MAX_LENGTH_EXCEEDED; + } + + if (length != 1) { + // Read the remaining bytes. + input.readFully(scratch, 1, length - 1); + } + + state = STATE_BEGIN_READING; + return assembleVarint(scratch, length, removeLengthMask); + } + + /** Returns the number of bytes occupied by the most recently parsed varint. */ + public int getLastLength() { + return length; + } + + /** + * Parses and the length of the varint given the first byte. + * + * @param firstByte First byte of the varint. + * @return Length of the varint beginning with the given byte if it was valid, {@link + * C#LENGTH_UNSET} otherwise. + */ + public static int parseUnsignedVarintLength(int firstByte) { + int varIntLength = C.LENGTH_UNSET; + for (int i = 0; i < VARINT_LENGTH_MASKS.length; i++) { + if ((VARINT_LENGTH_MASKS[i] & firstByte) != 0) { + varIntLength = i + 1; + break; + } + } + return varIntLength; + } + + /** + * Assemble a varint from the given byte array. + * + * @param varintBytes Bytes that make up the varint. + * @param varintLength Length of the varint to assemble. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @return Parsed and assembled varint. + */ + public static long assembleVarint( + byte[] varintBytes, int varintLength, boolean removeLengthMask) { + long varint = varintBytes[0] & 0xFFL; + if (removeLengthMask) { + varint &= ~VARINT_LENGTH_MASKS[varintLength - 1]; + } + for (int i = 1; i < varintLength; i++) { + varint = (varint << 8) | (varintBytes[i] & 0xFFL); + } + return varint; + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/OrbitMatroskaTest.kt b/android/app/src/test/java/tv/orbit/classic/OrbitMatroskaTest.kt new file mode 100644 index 0000000..8344358 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/OrbitMatroskaTest.kt @@ -0,0 +1,29 @@ +package tv.orbit.classic + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Orbit's Matroska reader: H.264 in the Video for Windows wrapper gets a picture (Dragon Ball GT, 4 November 2026); nothing else changes. */ +class OrbitMatroskaTest { + private fun code(fourCc: String) = ByteBuffer.wrap(fourCc.toByteArray(Charsets.US_ASCII)).order(ByteOrder.LITTLE_ENDIAN).int.toLong() and 0xFFFFFFFFL + + @Test fun h264InTheOldWrapperIsReadAsH264() { + for (fourCc in listOf("H264", "h264", "X264", "x264", "AVC1", "avc1", "DAVC", "VSSH")) assertTrue(fourCc, tv.orbit.classic.mkv.MatroskaExtractor.h264FourCc(code(fourCc))) + } + + @Test fun otherFourCcsAreLeftToMedia3() { + for (fourCc in listOf("XVID", "DIVX", "H263", "WVC1", "ABCD")) assertFalse(fourCc, tv.orbit.classic.mkv.MatroskaExtractor.h264FourCc(code(fourCc))) + } + + @Test fun onlyTheMatroskaReaderIsSwapped() { + val factory = OrbitExtractorsFactory() + val media3 = arrayOf(androidx.media3.extractor.mp4.Mp4Extractor(androidx.media3.extractor.text.DefaultSubtitleParserFactory()), + androidx.media3.extractor.mkv.MatroskaExtractor(androidx.media3.extractor.text.DefaultSubtitleParserFactory())) + val ours = factory.swap(media3) + assertTrue(ours[0] === media3[0]) + assertTrue(ours[1] is tv.orbit.classic.mkv.MatroskaExtractor) + } +}