diff --git a/libraries/common/src/main/java/androidx/media3/common/FileTypes.java b/libraries/common/src/main/java/androidx/media3/common/FileTypes.java index 38324d2f1ec..c88aa5470dc 100644 --- a/libraries/common/src/main/java/androidx/media3/common/FileTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/FileTypes.java @@ -59,6 +59,7 @@ public final class FileTypes { *
  • {@link #BMP} *
  • {@link #HEIF} *
  • {@link #AVIF} + *
  • {@link #MMT_TLV} * */ @Documented @@ -66,7 +67,7 @@ public final class FileTypes { @Target(TYPE_USE) @IntDef({ UNKNOWN, AC3, AC4, ADTS, AMR, FLAC, FLV, MATROSKA, MP3, MP4, OGG, PS, TS, WAV, WEBVTT, JPEG, - MIDI, AVI, PNG, WEBP, BMP, HEIF, AVIF + MIDI, AVI, PNG, WEBP, BMP, HEIF, AVIF, MMT_TLV }) public @interface Type {} @@ -139,6 +140,9 @@ public final class FileTypes { /** File type for the AVIF format. */ public static final int AVIF = 21; + /** File type for the MMT (MPEG Media Transport) over TLV format. */ + public static final int MMT_TLV = 22; + @VisibleForTesting /* package */ static final String HEADER_CONTENT_TYPE = "Content-Type"; private static final String EXTENSION_AC3 = ".ac3"; @@ -181,6 +185,9 @@ public final class FileTypes { private static final String EXTENSION_HEIC = ".heic"; private static final String EXTENSION_HEIF = ".heif"; private static final String EXTENSION_AVIF = ".avif"; + private static final String EXTENSION_MMT = ".mmt"; + private static final String EXTENSION_MMTS = ".mmts"; + private static final String EXTENSION_TLV = ".tlv"; private FileTypes() {} @@ -257,6 +264,8 @@ private FileTypes() {} return FileTypes.HEIF; case MimeTypes.IMAGE_AVIF: return FileTypes.AVIF; + case MimeTypes.VIDEO_MMT_TLV: + return FileTypes.MMT_TLV; default: return FileTypes.UNKNOWN; } @@ -334,6 +343,10 @@ private FileTypes() {} return FileTypes.HEIF; } else if (filename.endsWith(EXTENSION_AVIF)) { return FileTypes.AVIF; + } else if (filename.endsWith(EXTENSION_MMT) + || filename.endsWith(EXTENSION_MMTS) + || filename.endsWith(EXTENSION_TLV)) { + return FileTypes.MMT_TLV; } else { return FileTypes.UNKNOWN; } diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java index 134bafc85fd..7b66df2b923 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java @@ -53,6 +53,7 @@ public final class MimeTypes { @UnstableApi public static final String VIDEO_VP9 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp9"; public static final String VIDEO_AV1 = BASE_TYPE_VIDEO + "/av01"; public static final String VIDEO_MP2T = BASE_TYPE_VIDEO + "/mp2t"; + @UnstableApi public static final String VIDEO_MMT_TLV = BASE_TYPE_VIDEO + "/mmt-tlv"; public static final String VIDEO_MP4V = BASE_TYPE_VIDEO + "/mp4v-es"; public static final String VIDEO_MPEG = BASE_TYPE_VIDEO + "/mpeg"; public static final String VIDEO_PS = BASE_TYPE_VIDEO + "/mp2p"; diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java b/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java index 03f062508b1..bd339abd7f9 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java @@ -37,6 +37,7 @@ import androidx.media3.extractor.heif.HeifExtractor; import androidx.media3.extractor.jpeg.JpegExtractor; import androidx.media3.extractor.mkv.MatroskaExtractor; +import androidx.media3.extractor.mmt.TlvExtractor; import androidx.media3.extractor.mp3.Mp3Extractor; import androidx.media3.extractor.mp4.FragmentedMp4Extractor; import androidx.media3.extractor.mp4.Mp4Extractor; @@ -94,6 +95,7 @@ *
  • BMP ({@link BmpExtractor}) *
  • HEIF ({@link HeifExtractor}) *
  • AVIF ({@link AvifExtractor}) + *
  • MMT/TLV ({@link TlvExtractor}) *
  • MIDI, if available, the MIDI extension's {@code androidx.media3.decoder.midi.MidiExtractor} * is used. * @@ -129,7 +131,8 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { FileTypes.WEBP, FileTypes.BMP, FileTypes.HEIF, - FileTypes.AVIF + FileTypes.AVIF, + FileTypes.MMT_TLV }; private static final ExtensionLoader FLAC_EXTENSION_LOADER = @@ -613,6 +616,9 @@ private void addExtractorsForFileType(@FileTypes.Type int fileType, ListOnly the subset required to map each media {@code packet_id} to a codec is parsed: + * + *
    + *   MMTP signaling payload  -> PA message (message_id 0x0000)  -> MMT Package Table (id 0x20)
    + * 
    + * + *

    The MMT Package Table (MPT) lists the assets that make up the package; for each asset it gives + * a four character {@code asset_type} (for example {@code hev1}) and one or more locations. Assets + * delivered on the same MMTP flow use location type {@code 0x00}, which carries the {@code + * packet_id} directly. + * + *

    Observed NHK BS4K/BS8K packages, for reference: + * + *

    + * + *

    These streams set {@code number_of_tables} to 0 in the PA message and inline the tables + * directly, so {@link #parsePaMessage} walks the concatenated tables rather than trusting the + * (empty) table index. + */ +/* package */ final class MmtSignalingParser { + + /** A media asset discovered in the MMT Package Table. */ + public static final class Asset { + /** The {@code packet_id} of the MMTP flow carrying the asset. */ + public final int packetId; + + /** The MMT {@code asset_type}, a four character code (see {@code ASSET_TYPE_*}). */ + public final int assetType; + + public Asset(int packetId, int assetType) { + this.packetId = packetId; + this.assetType = assetType; + } + } + + /** {@code asset_type} four character codes. */ + public static final int ASSET_TYPE_HEV1 = 0x68657631; // "hev1" + + public static final int ASSET_TYPE_HVC1 = 0x68766331; // "hvc1" + public static final int ASSET_TYPE_AVC1 = 0x61766331; // "avc1" + public static final int ASSET_TYPE_AVC3 = 0x61766333; // "avc3" + + private static final String TAG = "MmtSignalingParser"; + + private static final int MESSAGE_ID_PA = 0x0000; + private static final int TABLE_ID_MPT = 0x20; + private static final int LOCATION_TYPE_SAME_FLOW = 0x00; + private static final int LOCATION_TYPE_IPV4 = 0x01; + private static final int LOCATION_TYPE_IPV6 = 0x02; + + // Signaling payload fragmentation indicator values. + private static final int FI_COMPLETE = 0; + private static final int FI_FIRST = 1; + private static final int FI_MIDDLE = 2; + private static final int FI_LAST = 3; + + private final ParsableByteArray messageBuffer; + + private ImmutableList assets; + private boolean assembling; + + public MmtSignalingParser() { + messageBuffer = new ParsableByteArray(/* limit= */ 0); + assets = ImmutableList.of(); + } + + /** Returns the assets discovered so far. */ + public ImmutableList getAssets() { + return assets; + } + + /** + * Consumes a signaling MMTP payload, positioned at the start of the signaling message header. + * + * @return Whether the set of {@linkplain #getAssets() assets} was updated. + */ + public boolean consume(ParsableByteArray payload) { + if (payload.bytesLeft() < 2) { + return false; + } + int header = payload.readUnsignedByte(); + int fragmentationIndicator = (header >> 6) & 0x3; + boolean lengthExtensionFlag = ((header >> 1) & 0x1) != 0; + boolean aggregated = (header & 0x1) != 0; + payload.skipBytes(1); // fragmentation_counter. + + if (aggregated) { + boolean updated = false; + while (payload.bytesLeft() > (lengthExtensionFlag ? 4 : 2)) { + long messageLength = + lengthExtensionFlag ? payload.readUnsignedInt() : payload.readUnsignedShort(); + if (messageLength <= 0 || messageLength > payload.bytesLeft()) { + break; + } + int limit = payload.getPosition() + (int) messageLength; + updated |= parseSignalingMessage(payload, limit); + payload.setPosition(limit); + } + return updated; + } + + // Non-aggregated: the payload contains one (possibly fragmented) signaling message. + switch (fragmentationIndicator) { + case FI_COMPLETE: + return parseSignalingMessage(payload, payload.limit()); + case FI_FIRST: + startReassembly(); + appendToMessageBuffer(payload); + return false; + case FI_MIDDLE: + if (assembling) { + appendToMessageBuffer(payload); + } + return false; + case FI_LAST: + if (!assembling) { + return false; + } + appendToMessageBuffer(payload); + assembling = false; + messageBuffer.setPosition(0); + return parseSignalingMessage(messageBuffer, messageBuffer.limit()); + default: + return false; + } + } + + private void startReassembly() { + messageBuffer.setPosition(0); + messageBuffer.setLimit(0); + assembling = true; + } + + private void appendToMessageBuffer(ParsableByteArray payload) { + int length = payload.bytesLeft(); + int currentLimit = messageBuffer.limit(); + int required = currentLimit + length; + if (required > messageBuffer.capacity()) { + byte[] grown = new byte[max(messageBuffer.capacity() * 2, required)]; + System.arraycopy(messageBuffer.getData(), 0, grown, 0, currentLimit); + messageBuffer.reset(grown, currentLimit); + } + System.arraycopy( + payload.getData(), payload.getPosition(), messageBuffer.getData(), currentLimit, length); + messageBuffer.setLimit(required); + } + + /** Parses a single signaling message bounded by {@code limit}. Returns whether assets changed. */ + private boolean parseSignalingMessage(ParsableByteArray data, int limit) { + try { + if (limit - data.getPosition() < 3) { + return false; + } + int messageId = data.readUnsignedShort(); + data.skipBytes(1); // version. + if (messageId != MESSAGE_ID_PA) { + // Only the PA message (which carries the MMT Package Table) is needed for asset discovery. + return false; + } + // PA message: length is a 32-bit field. + if (limit - data.getPosition() < 5) { + return false; + } + data.skipBytes(4); // length. + return parsePaMessage(data, limit); + } catch (RuntimeException e) { + Log.w(TAG, "Error parsing signaling message", e); + return false; + } + } + + private boolean parsePaMessage(ParsableByteArray data, int limit) { + if (limit - data.getPosition() < 1) { + return false; + } + // The PA message begins with number_of_tables followed by an index of {table_id (8), + // table_version (8), table_length (16)} entries, and then the tables themselves. In practice + // ARIB STD-B60 streams (e.g. NHK BS4K/BS8K) set number_of_tables to 0 and inline the tables + // directly, so the index cannot be relied upon. We therefore skip the index (when present) and + // then walk the concatenated tables by their own {table_id, version, table_length} headers, + // which is robust to both layouts. + int numberOfTables = data.readUnsignedByte(); + data.skipBytes(Math.min(numberOfTables * 4, Math.max(0, limit - data.getPosition()))); + boolean updated = false; + while (limit - data.getPosition() >= 4) { + int tableStart = data.getPosition(); + int tableId = data.getData()[tableStart] & 0xFF; + int tableLength = ((data.getData()[tableStart + 2] & 0xFF) << 8) | (data.getData()[tableStart + 3] & 0xFF); + int tableLimit = Math.min(limit, tableStart + 4 + tableLength); + if (tableId == TABLE_ID_MPT) { + List parsed = parseMmtPackageTable(data, tableLimit); + if (parsed != null && !parsed.isEmpty()) { + assets = ImmutableList.copyOf(parsed); + updated = true; + } + } + if (tableLimit <= tableStart) { + break; // Guard against a zero-length table causing an infinite loop. + } + data.setPosition(tableLimit); + } + return updated; + } + + /** Parses an MMT Package Table. Returns the assets, or {@code null} if parsing failed. */ + private List parseMmtPackageTable(ParsableByteArray data, int limit) { + if (limit - data.getPosition() < 4) { + return null; + } + data.skipBytes(1); // table_id (0x20). + data.skipBytes(1); // version. + data.skipBytes(2); // length. + if (limit - data.getPosition() < 2) { + return null; + } + data.skipBytes(1); // reserved (6 bits) + MPT_mode (2 bits). + int packageIdLength = data.readUnsignedByte(); + if (limit - data.getPosition() < packageIdLength + 2) { + return null; + } + data.skipBytes(packageIdLength); // MMT_package_id_byte. + int descriptorsLength = data.readUnsignedShort(); + if (limit - data.getPosition() < descriptorsLength + 1) { + return null; + } + data.skipBytes(descriptorsLength); // MPT_descriptors_byte. + int numberOfAssets = data.readUnsignedByte(); + List parsed = new ArrayList<>(); + for (int i = 0; i < numberOfAssets; i++) { + if (!parseAsset(data, limit, parsed)) { + break; + } + } + return parsed; + } + + /** Parses a single asset entry, appending it to {@code out}. Returns whether to keep parsing. */ + private boolean parseAsset(ParsableByteArray data, int limit, List out) { + if (limit - data.getPosition() < 6) { + return false; + } + data.skipBytes(1); // identifier_type. + data.skipBytes(4); // asset_id_scheme. + int assetIdLength = data.readUnsignedByte(); + if (limit - data.getPosition() < assetIdLength + 4 + 1 + 1) { + return false; + } + data.skipBytes(assetIdLength); // asset_id_byte. + int assetType = data.readInt(); // asset_type four character code. + data.skipBytes(1); // reserved (7 bits) + asset_clock_relation_flag (1 bit). + int locationCount = data.readUnsignedByte(); + int packetId = -1; + for (int i = 0; i < locationCount; i++) { + if (limit - data.getPosition() < 1) { + return false; + } + int locationType = data.readUnsignedByte(); + switch (locationType) { + case LOCATION_TYPE_SAME_FLOW: + if (limit - data.getPosition() < 2) { + return false; + } + int candidate = data.readUnsignedShort(); + if (packetId == -1) { + packetId = candidate; + } + break; + case LOCATION_TYPE_IPV4: + data.skipBytes(12); // src_addr(4) + dst_addr(4) + dst_port(2) + packet_id(2). + break; + case LOCATION_TYPE_IPV6: + data.skipBytes(36); // src_addr(16) + dst_addr(16) + dst_port(2) + packet_id(2). + break; + default: + // TODO: Handle broadcast / URL location types. Bail out to avoid mis-parsing. + return false; + } + } + if (limit - data.getPosition() < 2) { + return false; + } + int assetDescriptorsLength = data.readUnsignedShort(); + if (limit - data.getPosition() < assetDescriptorsLength) { + return false; + } + // TODO: Parse MPU_timestamp_descriptor here to anchor presentation times. + data.skipBytes(assetDescriptorsLength); + if (packetId != -1) { + out.add(new Asset(packetId, assetType)); + } + return true; + } +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/MmtpReader.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/MmtpReader.java new file mode 100644 index 00000000000..a010ce3453d --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/MmtpReader.java @@ -0,0 +1,451 @@ +/* + * Copyright 2024 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 androidx.media3.extractor.mmt; + +import static java.lang.Math.max; + +import android.util.SparseArray; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ts.ElementaryStreamReader; +import androidx.media3.extractor.ts.H264Reader; +import androidx.media3.extractor.ts.H265Reader; +import androidx.media3.extractor.ts.SeiReader; +import androidx.media3.extractor.ts.TsPayloadReader; +import com.google.common.collect.ImmutableList; + +/** + * Parses MMTP (MMT Protocol) packets extracted from the TLV layer by {@link TlvExtractor} and + * produces media samples. + * + *

    Responsibilities: + * + *

    + */ +/* package */ final class MmtpReader { + + private static final String TAG = "MmtpReader"; + + /** MMTP payload types (ISO/IEC 23008-1 Table 8). */ + private static final int PAYLOAD_TYPE_MPU = 0x00; + + private static final int PAYLOAD_TYPE_SIGNALLING = 0x02; + + /** MPU fragment types (ISO/IEC 23008-1 §9.4). */ + private static final int MPU_FRAGMENT_TYPE_MPU_METADATA = 0x00; + + private static final int MPU_FRAGMENT_TYPE_MOVIE_FRAGMENT_METADATA = 0x01; + private static final int MPU_FRAGMENT_TYPE_MFU = 0x02; + + /** Fragmentation indicator values (ISO/IEC 23008-1 §9.4). */ + private static final int FI_COMPLETE = 0; + + private static final int FI_FIRST = 1; + private static final int FI_MIDDLE = 2; + private static final int FI_LAST = 3; + + private final MmtSignalingParser signalingParser; + private final SparseArray assemblersByPacketId; + private final TsPayloadReader.TrackIdGenerator idGenerator; + + @Nullable private ExtractorOutput output; + private boolean packageResolved; + private boolean tracksEnded; + + public MmtpReader() { + signalingParser = new MmtSignalingParser(); + assemblersByPacketId = new SparseArray<>(); + // Match the id scheme used by the TS extractor so downstream renderers see stable ids. + idGenerator = new TsPayloadReader.TrackIdGenerator(/* firstTrackId= */ 0, /* trackIdIncrement= */ 1); + } + + public void init(ExtractorOutput output) { + this.output = output; + } + + public void seek() { + for (int i = 0; i < assemblersByPacketId.size(); i++) { + assemblersByPacketId.valueAt(i).reset(); + } + } + + /** + * Signals to the caller whether all media tracks have been declared. Tracks are declared lazily + * once the MMT Package Table has been parsed. + * + * @return Whether {@link ExtractorOutput#endTracks()} has been called. + */ + public boolean maybeEndTracks() { + if (!tracksEnded && packageResolved && output != null) { + output.endTracks(); + output.seekMap(TlvExtractor.createUnseekableSeekMap()); + tracksEnded = true; + } + return tracksEnded; + } + + /** Consumes a single MMTP packet, whose position is set to the start of the MMTP header. */ + public void consume(ParsableByteArray packet) { + if (packet.bytesLeft() < 2) { + return; + } + int b0 = packet.readUnsignedByte(); + int b1 = packet.readUnsignedByte(); + boolean packetCounterFlag = ((b0 >> 5) & 0x1) != 0; + boolean extensionFlag = ((b0 >> 1) & 0x1) != 0; + int payloadType = b1 & 0x3F; + if (packet.bytesLeft() < 10) { + return; + } + int packetId = packet.readUnsignedShort(); + long timestamp = packet.readUnsignedInt(); // NTP short-format transmission timestamp. + packet.skipBytes(4); // packet_sequence_number. + if (packetCounterFlag) { + if (packet.bytesLeft() < 4) { + return; + } + packet.skipBytes(4); // packet_counter. + } + if (extensionFlag) { + if (packet.bytesLeft() < 4) { + return; + } + packet.skipBytes(2); // extension_header_type. + int extensionLength = packet.readUnsignedShort(); + if (packet.bytesLeft() < extensionLength) { + return; + } + packet.skipBytes(extensionLength); + } + switch (payloadType) { + case PAYLOAD_TYPE_SIGNALLING: + consumeSignalling(packet); + break; + case PAYLOAD_TYPE_MPU: + consumeMpu(packetId, timestamp, packet); + break; + default: + // FEC repair and other payload types are ignored. + break; + } + } + + private void consumeSignalling(ParsableByteArray payload) { + if (signalingParser.consume(payload)) { + // The package table has (re)appeared. (Re)declare tracks for any newly discovered assets. + maybeCreateAssemblers(); + } + } + + private void maybeCreateAssemblers() { + if (output == null || tracksEnded) { + return; + } + ImmutableList assets = signalingParser.getAssets(); + for (int i = 0; i < assets.size(); i++) { + MmtSignalingParser.Asset asset = assets.get(i); + if (assemblersByPacketId.get(asset.packetId) != null) { + continue; + } + @Nullable ElementaryStreamReader reader = createReader(asset.assetType); + if (reader == null) { + continue; + } + reader.createTracks(output, idGenerator); + assemblersByPacketId.put(asset.packetId, new MpuAssembler(reader, asset.assetType)); + } + if (assemblersByPacketId.size() > 0) { + packageResolved = true; + } + } + + /** + * Creates an {@link ElementaryStreamReader} for the given MMT asset type (a four character code + * such as {@code hev1} or {@code avc1}), or {@code null} if the asset type is not supported yet. + */ + @Nullable + private ElementaryStreamReader createReader(int assetType) { + switch (assetType) { + case MmtSignalingParser.ASSET_TYPE_HEV1: + case MmtSignalingParser.ASSET_TYPE_HVC1: + return new H265Reader( + new SeiReader(/* closedCaptionFormats= */ ImmutableList.of(), MimeTypes.VIDEO_H265), + MimeTypes.VIDEO_H265); + case MmtSignalingParser.ASSET_TYPE_AVC1: + case MmtSignalingParser.ASSET_TYPE_AVC3: + return new H264Reader( + new SeiReader(/* closedCaptionFormats= */ ImmutableList.of(), MimeTypes.VIDEO_H264), + /* allowNonIdrKeyframes= */ false, + /* detectAccessUnits= */ true, + MimeTypes.VIDEO_H264); + default: + // TODO: Add MH-AAC / MPEG-H 3D audio ('mp4a', 'mh4a') and ARIB subtitle asset support. + return null; + } + } + + private void consumeMpu(int packetId, long timestamp, ParsableByteArray payload) { + @Nullable MpuAssembler assembler = assemblersByPacketId.get(packetId); + if (assembler == null) { + // Either signaling has not been parsed yet, or this asset type is unsupported. + return; + } + if (payload.bytesLeft() < 8) { + return; + } + payload.skipBytes(2); // MMTP payload length (redundant with the TLV/UDP lengths). + int header = payload.readUnsignedByte(); + int fragmentType = (header >> 4) & 0x0F; + boolean timed = ((header >> 3) & 0x1) != 0; + int fragmentationIndicator = (header >> 1) & 0x3; + boolean aggregated = (header & 0x1) != 0; + payload.skipBytes(1); // fragmentation_counter. + long mpuSequenceNumber = payload.readUnsignedInt(); + + if (fragmentType != MPU_FRAGMENT_TYPE_MFU) { + // MPU metadata / movie fragment metadata carry ISO-BMFF boxes; used only to refine timing. + if (fragmentType == MPU_FRAGMENT_TYPE_MPU_METADATA + || fragmentType == MPU_FRAGMENT_TYPE_MOVIE_FRAGMENT_METADATA) { + assembler.consumeMetadata(fragmentType, mpuSequenceNumber, payload); + } + return; + } + + try { + if (aggregated) { + while (payload.bytesLeft() > 2) { + int dataUnitLength = payload.readUnsignedShort(); + if (dataUnitLength <= 0 || dataUnitLength > payload.bytesLeft()) { + break; + } + int limit = payload.getPosition() + dataUnitLength; + assembler.consumeTimedDataUnit( + FI_COMPLETE, timed, mpuSequenceNumber, timestamp, payload, limit); + payload.setPosition(limit); + } + } else { + assembler.consumeTimedDataUnit( + fragmentationIndicator, + timed, + mpuSequenceNumber, + timestamp, + payload, + payload.limit()); + } + } catch (ParserException e) { + Log.w(TAG, "Discarding malformed MPU on packet_id " + packetId, e); + assembler.reset(); + } + } + + /** + * Reassembles the MFU (Media Fragment Unit) data units of a single asset ({@code packet_id}) into + * access units and forwards them to an {@link ElementaryStreamReader}. + * + *

    For timed HEVC/AVC assets each access unit is a set of length-prefixed NAL units. They are + * converted to Annex-B (start-code delimited) form so that the existing {@link H265Reader} / + * {@link H264Reader} elementary stream readers can extract the samples and derive the {@link + * androidx.media3.common.Format} from the in-band parameter sets. + */ + private static final class MpuAssembler { + + /** Default presentation duration per sample when timing metadata is unavailable (30 fps). */ + private static final long DEFAULT_SAMPLE_DURATION_US = C.MICROS_PER_SECOND / 30; + + private static final int NAL_LENGTH_FIELD_SIZE = 4; + + private final ElementaryStreamReader reader; + private final boolean isVideo; + private final ParsableByteArray sampleData; + + private long sampleDurationUs; + private long nextSampleTimeUs; + private boolean assembling; + + public MpuAssembler(ElementaryStreamReader reader, int assetType) { + this.reader = reader; + this.isVideo = + assetType == MmtSignalingParser.ASSET_TYPE_HEV1 + || assetType == MmtSignalingParser.ASSET_TYPE_HVC1 + || assetType == MmtSignalingParser.ASSET_TYPE_AVC1 + || assetType == MmtSignalingParser.ASSET_TYPE_AVC3; + sampleData = new ParsableByteArray(/* limit= */ 0); + sampleDurationUs = DEFAULT_SAMPLE_DURATION_US; + nextSampleTimeUs = 0; + } + + public void reset() { + reader.seek(); + sampleData.setPosition(0); + sampleData.setLimit(0); + assembling = false; + } + + /** Parses ISO-BMFF metadata boxes to refine the per-sample duration when possible. */ + public void consumeMetadata(int fragmentType, long mpuSequenceNumber, ParsableByteArray payload) { + // TODO: Parse moov (mdhd timescale) and moof (tfhd/trun default_sample_duration) to derive + // exact per-sample presentation times, and anchor them with the MPU_timestamp_descriptor from + // the MMT Package Table. Until then a fixed frame rate is assumed for video assets. + } + + /** + * Consumes a single MFU data unit, reassembling fragmented access units. + * + * @param fragmentationIndicator One of the {@code FI_*} constants. + * @param timed Whether the MPU is a timed asset. + * @param mpuSequenceNumber The MPU sequence number (unused for now, see {@link + * #consumeMetadata}). + * @param mmtpTimestampNtp The MMTP transmission timestamp (NTP short format), used only as a + * coarse anchor for the first sample. + * @param payload The payload, positioned at the start of the data unit. + * @param limit The exclusive end position of this data unit within {@code payload}. + */ + public void consumeTimedDataUnit( + int fragmentationIndicator, + boolean timed, + long mpuSequenceNumber, + long mmtpTimestampNtp, + ParsableByteArray payload, + int limit) + throws ParserException { + boolean hasDataUnitHeader = + fragmentationIndicator == FI_COMPLETE || fragmentationIndicator == FI_FIRST; + if (timed && hasDataUnitHeader) { + // Skip the 16-byte timed MFU data unit header: movie_fragment_sequence_number (4), + // sample_number (4), offset (4), priority (1), dependency_counter (1) + 2 reserved. + if (payload.getPosition() + 16 > limit) { + throw ParserException.createForMalformedContainer( + "Invalid timed MFU data unit", /* cause= */ null); + } + payload.skipBytes(16); + } + int length = limit - payload.getPosition(); + if (length <= 0) { + return; + } + switch (fragmentationIndicator) { + case FI_COMPLETE: + startSample(); + appendSampleData(payload, length); + emitSample(mmtpTimestampNtp); + break; + case FI_FIRST: + startSample(); + appendSampleData(payload, length); + break; + case FI_MIDDLE: + if (assembling) { + appendSampleData(payload, length); + } else { + payload.skipBytes(length); + } + break; + case FI_LAST: + if (assembling) { + appendSampleData(payload, length); + emitSample(mmtpTimestampNtp); + } else { + payload.skipBytes(length); + } + break; + default: + payload.skipBytes(length); + break; + } + } + + private void startSample() { + sampleData.setPosition(0); + sampleData.setLimit(0); + assembling = true; + } + + private void appendSampleData(ParsableByteArray payload, int length) { + int currentLimit = sampleData.limit(); + int requiredCapacity = currentLimit + length; + if (requiredCapacity > sampleData.capacity()) { + byte[] grown = new byte[max(sampleData.capacity() * 2, requiredCapacity)]; + System.arraycopy(sampleData.getData(), 0, grown, 0, currentLimit); + sampleData.reset(grown, currentLimit); + } + System.arraycopy( + payload.getData(), payload.getPosition(), sampleData.getData(), currentLimit, length); + payload.skipBytes(length); + sampleData.setLimit(requiredCapacity); + } + + private void emitSample(long mmtpTimestampNtp) { + if (!assembling) { + return; + } + assembling = false; + int sampleSize = sampleData.limit(); + if (sampleSize <= 0) { + return; + } + if (isVideo) { + convertLengthPrefixedNalUnitsToAnnexB(); + } + long timeUs = nextSampleTimeUs; + nextSampleTimeUs += sampleDurationUs; + sampleData.setPosition(0); + reader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR); + try { + reader.consume(sampleData); + reader.packetFinished(); + } catch (ParserException e) { + Log.w(TAG, "Discarding sample that could not be parsed", e); + } + } + + /** + * Rewrites the reassembled access unit in place, replacing each 4-byte NAL unit length prefix + * with a 4-byte Annex-B start code ({@code 00 00 00 01}). + */ + private void convertLengthPrefixedNalUnitsToAnnexB() { + byte[] data = sampleData.getData(); + int limit = sampleData.limit(); + int position = 0; + while (position + NAL_LENGTH_FIELD_SIZE <= limit) { + int nalLength = + ((data[position] & 0xFF) << 24) + | ((data[position + 1] & 0xFF) << 16) + | ((data[position + 2] & 0xFF) << 8) + | (data[position + 3] & 0xFF); + if (nalLength <= 0 || position + NAL_LENGTH_FIELD_SIZE + nalLength > limit) { + // Not length-prefixed (or corrupt); leave the remainder untouched. + break; + } + data[position] = 0x00; + data[position + 1] = 0x00; + data[position + 2] = 0x00; + data[position + 3] = 0x01; + position += NAL_LENGTH_FIELD_SIZE + nalLength; + } + } + } +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java new file mode 100644 index 00000000000..c031776a455 --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/TlvExtractor.java @@ -0,0 +1,317 @@ +/* + * Copyright 2024 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 androidx.media3.extractor.mmt; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.Math.max; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import java.io.EOFException; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Extracts data from an MMT (MPEG Media Transport) elementary stream carried in TLV + * (Type-Length-Value) packets, as used by ISDB-S3 4K/8K broadcasting (ARIB STD-B32 / STD-B60). + * + *

    This extractor is responsible for the outermost transport layers only: it parses TLV packets, + * unwraps the IPv4/IPv6/header-compressed IP + UDP encapsulation, and passes the resulting MMTP + * packets to an {@link MmtpReader}. The {@link MmtpReader} performs MMTP header parsing, MPU/MFU + * reassembly and signaling handling to produce media samples. + * + *

    TLV packet syntax (ARIB STD-B32 Part 3): + * + *

    + *   TLV_packet() {
    + *     sync_byte          8 bits   // fixed 0x7F
    + *     packet_type        8 bits   // 0x01 IPv4, 0x02 IPv6, 0x03 header-compressed IP,
    + *                                 // 0xFE transmission control signal, 0xFF null
    + *     data_length       16 bits
    + *     data_byte         data_length bytes
    + *   }
    + * 
    + */ +@UnstableApi +public final class TlvExtractor implements Extractor { + + /** Factory for {@link TlvExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new TlvExtractor()}; + + /** Fixed value of the first byte of every TLV packet. */ + public static final int TLV_SYNC_BYTE = 0x7F; + + private static final int TLV_PACKET_TYPE_IPV4 = 0x01; + private static final int TLV_PACKET_TYPE_IPV6 = 0x02; + private static final int TLV_PACKET_TYPE_COMPRESSED_IP = 0x03; + private static final int TLV_PACKET_TYPE_SIGNALLING = 0xFE; + private static final int TLV_PACKET_TYPE_NULL = 0xFF; + + private static final int TLV_HEADER_SIZE = 4; + private static final int IP_PROTOCOL_UDP = 17; + private static final int UDP_HEADER_SIZE = 8; + private static final int IPV6_HEADER_SIZE = 40; + + /** Number of consecutive well-formed TLV packets required for a successful {@link #sniff}. */ + private static final int SNIFF_TLV_PACKET_COUNT = 5; + + private final ParsableByteArray tlvHeader; + private final ParsableByteArray tlvPayload; + private final MmtpReader mmtpReader; + + private @MonotonicNonNull ExtractorOutput output; + private boolean tracksEnded; + + public TlvExtractor() { + tlvHeader = new ParsableByteArray(TLV_HEADER_SIZE); + tlvPayload = new ParsableByteArray(/* limit= */ 0); + mmtpReader = new MmtpReader(); + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException { + byte[] header = new byte[TLV_HEADER_SIZE]; + int bytesPeeked = 0; + for (int i = 0; i < SNIFF_TLV_PACKET_COUNT; i++) { + if (!input.peekFully(header, /* offset= */ 0, TLV_HEADER_SIZE, /* allowEndOfInput= */ true)) { + return i > 0; + } + if ((header[0] & 0xFF) != TLV_SYNC_BYTE) { + return false; + } + int packetType = header[1] & 0xFF; + if (!isKnownPacketType(packetType)) { + return false; + } + int dataLength = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF); + try { + input.advancePeekPosition(dataLength); + } catch (EOFException e) { + // The stream ended mid-packet; treat as a match if we already saw a well-formed packet. + return i > 0; + } + bytesPeeked += TLV_HEADER_SIZE + dataLength; + // Reset the peek buffer periodically so we don't require an unbounded peek window. + if (bytesPeeked > 512 * 1024) { + break; + } + } + return true; + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + mmtpReader.init(output); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + checkNotNull(output); + if (!readTlvHeader(input)) { + return RESULT_END_OF_INPUT; + } + int packetType = tlvHeader.getData()[1] & 0xFF; + tlvHeader.setPosition(2); + int dataLength = tlvHeader.readUnsignedShort(); + if (dataLength > 0) { + prepareTlvPayload(input, dataLength); + processTlvPayload(packetType); + } + if (!tracksEnded) { + // Media tracks may be created lazily once signaling has been parsed. endTracks() is called by + // the reader when the package table has been resolved; before that we present no tracks. + tracksEnded = mmtpReader.maybeEndTracks(); + } + return RESULT_CONTINUE; + } + + @Override + public void seek(long position, long timeUs) { + // MMT/TLV streams are treated as live/unseekable for now (see seekMap in MmtpReader). + mmtpReader.seek(); + } + + @Override + public void release() { + // Do nothing. + } + + /** + * Reads the 4-byte TLV packet header into {@link #tlvHeader}, resynchronising on the sync byte if + * necessary. + * + * @return Whether a header was read. False indicates the end of the stream. + */ + private boolean readTlvHeader(ExtractorInput input) throws IOException { + // Resynchronise to the next sync byte to tolerate corrupted or partially delivered packets. + byte[] headerData = tlvHeader.getData(); + if (!input.readFully(headerData, /* offset= */ 0, /* length= */ 1, /* allowEndOfInput= */ true)) { + return false; + } + int resyncAttempts = 0; + while ((headerData[0] & 0xFF) != TLV_SYNC_BYTE) { + if (++resyncAttempts > 188 * 8) { + // Give up resynchronising after a reasonable amount of data. + return false; + } + if (!input.readFully(headerData, /* offset= */ 0, /* length= */ 1, /* allowEndOfInput= */ true)) { + return false; + } + } + if (!input.readFully( + headerData, /* offset= */ 1, /* length= */ TLV_HEADER_SIZE - 1, /* allowEndOfInput= */ true)) { + return false; + } + tlvHeader.setPosition(0); + return true; + } + + private void prepareTlvPayload(ExtractorInput input, int dataLength) throws IOException { + if (dataLength > tlvPayload.capacity()) { + tlvPayload.reset(new byte[max(tlvPayload.capacity() * 2, dataLength)], dataLength); + } else { + tlvPayload.setPosition(0); + tlvPayload.setLimit(dataLength); + } + input.readFully(tlvPayload.getData(), /* offset= */ 0, dataLength); + } + + private void processTlvPayload(int packetType) { + switch (packetType) { + case TLV_PACKET_TYPE_IPV4: + processIpv4Packet(tlvPayload); + break; + case TLV_PACKET_TYPE_IPV6: + processIpv6Packet(tlvPayload); + break; + case TLV_PACKET_TYPE_COMPRESSED_IP: + processCompressedIpPacket(tlvPayload); + break; + case TLV_PACKET_TYPE_SIGNALLING: + // Transmission control signals (TLV-NIT / AMT). Not required for media playback. + break; + case TLV_PACKET_TYPE_NULL: + default: + // Null packets are stuffing and are ignored. + break; + } + } + + /** Parses an uncompressed IPv4 datagram and dispatches the contained MMTP payload. */ + private void processIpv4Packet(ParsableByteArray packet) { + if (packet.bytesLeft() < 20) { + return; + } + int startPosition = packet.getPosition(); + int versionAndIhl = packet.readUnsignedByte(); + int version = versionAndIhl >> 4; + int headerLength = (versionAndIhl & 0x0F) * 4; + if (version != 4 || headerLength < 20) { + return; + } + packet.skipBytes(8); // ToS, total length, identification, flags/fragment offset, TTL. + int protocol = packet.readUnsignedByte(); + if (protocol != IP_PROTOCOL_UDP) { + return; + } + packet.setPosition(startPosition + headerLength); + dispatchUdpPayload(packet); + } + + /** Parses an uncompressed IPv6 datagram and dispatches the contained MMTP payload. */ + private void processIpv6Packet(ParsableByteArray packet) { + if (packet.bytesLeft() < IPV6_HEADER_SIZE) { + return; + } + int startPosition = packet.getPosition(); + int versionAndClass = packet.readUnsignedByte(); + if ((versionAndClass >> 4) != 6) { + return; + } + packet.skipBytes(5); // Remaining traffic class/flow label and payload length. + int nextHeader = packet.readUnsignedByte(); + if (nextHeader != IP_PROTOCOL_UDP) { + // TODO: Follow IPv6 extension header chain before the UDP header when present. + return; + } + packet.setPosition(startPosition + IPV6_HEADER_SIZE); + dispatchUdpPayload(packet); + } + + /** + * Parses an ARIB STD-B32 header-compressed IP packet. + * + *

    Only the "no compressed header" case (context type {@code 0x61}, where the payload follows a + * 2-byte context identification header) is currently unwrapped. Context establishing packets + * (types {@code 0x20}/{@code 0x21}) that carry a reconstructed IP header are skipped. + * + *

    TODO: Maintain per-CID header contexts and reconstruct compressed IP/UDP headers so that the + * transmitted timestamps and ports are available. + */ + private void processCompressedIpPacket(ParsableByteArray packet) { + if (packet.bytesLeft() < 3) { + return; + } + // context_id (12 bits) + sequence_number (4 bits) + context_identification_header_type (8 bits). + packet.skipBytes(2); // CID + SN. + int contextHeaderType = packet.readUnsignedByte(); + switch (contextHeaderType) { + case 0x61: // Compressed header for partial IPv6 header + partial UDP header. + case 0x60: // Compressed header for partial IPv4 header + partial UDP header. + // The MMTP payload immediately follows the context identification header. + dispatchMmtpPayload(packet); + break; + default: + // 0x20 / 0x21: full header transmission used to (re)establish a context. Skipped for now. + break; + } + } + + private void dispatchUdpPayload(ParsableByteArray packet) { + if (packet.bytesLeft() < UDP_HEADER_SIZE) { + return; + } + packet.skipBytes(UDP_HEADER_SIZE); // Source/destination port, length, checksum. + dispatchMmtpPayload(packet); + } + + private void dispatchMmtpPayload(ParsableByteArray packet) { + if (packet.bytesLeft() > 0) { + mmtpReader.consume(packet); + } + } + + private static boolean isKnownPacketType(int packetType) { + return packetType == TLV_PACKET_TYPE_IPV4 + || packetType == TLV_PACKET_TYPE_IPV6 + || packetType == TLV_PACKET_TYPE_COMPRESSED_IP + || packetType == TLV_PACKET_TYPE_SIGNALLING + || packetType == TLV_PACKET_TYPE_NULL; + } + + /** Provides an unseekable {@link SeekMap} for the (live) MMT/TLV stream. */ + static SeekMap createUnseekableSeekMap() { + return new SeekMap.Unseekable(C.TIME_UNSET); + } +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java new file mode 100644 index 00000000000..6725ceb1e13 --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mmt/package-info.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 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. + */ +/** + * Support for the MMT (MPEG Media Transport) over TLV (Type-Length-Value) transport stack used by + * ISDB-S3 based 4K/8K broadcasting (for example NHK BS4K/BS8K). + * + *

    The transport is layered as follows: + * + *

    + *   TLV packets
    + *     -> IPv4 / IPv6 / header-compressed IP datagrams (UDP)
    + *       -> MMTP packets
    + *         -> MPU / MFU fragments      -> HEVC / AVC access units
    + *         -> MMT signaling messages   -> PA message / MMT Package Table (asset discovery)
    + * 
    + */ +@NonNullApi +package androidx.media3.extractor.mmt; + +import androidx.media3.common.util.NonNullApi; diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java new file mode 100644 index 00000000000..15db52452ac --- /dev/null +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/MmtSignalingParserTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024 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 androidx.media3.extractor.mmt; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.test.utils.TestUtil; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link MmtSignalingParser}. */ +@RunWith(AndroidJUnit4.class) +public class MmtSignalingParserTest { + + /** + * A complete, non-aggregated signaling payload carrying a PA message whose MMT Package Table + * declares a single {@code hev1} asset delivered on {@code packet_id} 0x1001. + */ + private static final byte[] PA_MESSAGE_WITH_SINGLE_HEVC_ASSET = + TestUtil.createByteArray( + // Signaling payload header: FI=complete, LEF=0, A=0; fragmentation_counter=0. + 0x00, 0x00, + // PA signaling_message: message_id=0x0000, version=0x00, length (32-bit, unused)=27. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B, + // PA body: number_of_tables=0 and the tables inlined directly (as ARIB STD-B60 streams + // such as NHK BS4K/BS8K actually transmit them). + 0x00, + // MMT Package Table (26 bytes): + 0x20, 0x00, // table_id, version. + 0x00, 0x16, // table_length = 22 (bytes following this field). + 0x00, // reserved(6) + MPT_mode(2). + 0x00, // MMT_package_id_length. + 0x00, 0x00, // MPT_descriptors_length. + 0x01, // number_of_assets. + // Asset: + 0x00, // identifier_type. + 0x00, 0x00, 0x00, 0x00, // asset_id_scheme. + 0x00, // asset_id_length. + 0x68, 0x65, 0x76, 0x31, // asset_type = "hev1". + 0x00, // reserved(7) + asset_clock_relation_flag(1). + 0x01, // location_count. + 0x00, 0x10, 0x01, // location_type=0x00 (same flow), packet_id=0x1001. + 0x00, 0x00 // asset_descriptors_length. + ); + + @Test + public void consume_paMessageWithHevcAsset_discoversAsset() { + MmtSignalingParser parser = new MmtSignalingParser(); + + boolean updated = + parser.consume(new ParsableByteArray(PA_MESSAGE_WITH_SINGLE_HEVC_ASSET)); + + assertThat(updated).isTrue(); + ImmutableList assets = parser.getAssets(); + assertThat(assets).hasSize(1); + assertThat(assets.get(0).packetId).isEqualTo(0x1001); + assertThat(assets.get(0).assetType).isEqualTo(MmtSignalingParser.ASSET_TYPE_HEV1); + } + + @Test + public void consume_nonPaMessage_leavesAssetsEmpty() { + MmtSignalingParser parser = new MmtSignalingParser(); + // Signaling header (complete) + a message with message_id 0x8000 (not a PA message). + byte[] payload = TestUtil.createByteArray(0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00); + + boolean updated = parser.consume(new ParsableByteArray(payload)); + + assertThat(updated).isFalse(); + assertThat(parser.getAssets()).isEmpty(); + } +} diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/TlvExtractorTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/TlvExtractorTest.java new file mode 100644 index 00000000000..82121093223 --- /dev/null +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/mmt/TlvExtractorTest.java @@ -0,0 +1,116 @@ +/* + * Copyright 2024 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 androidx.media3.extractor.mmt; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.test.utils.FakeExtractorInput; +import androidx.media3.test.utils.FakeExtractorOutput; +import androidx.media3.test.utils.TestUtil; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.primitives.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link TlvExtractor}. */ +@RunWith(AndroidJUnit4.class) +public class TlvExtractorTest { + + /** A single TLV null packet: sync=0x7F, packet_type=0xFF (null), data_length=0. */ + private static final byte[] TLV_NULL_PACKET = TestUtil.createByteArray(0x7F, 0xFF, 0x00, 0x00); + + @Test + public void sniff_onTlvStream_returnsTrue() throws Exception { + byte[] data = + Bytes.concat( + TLV_NULL_PACKET, + TLV_NULL_PACKET, + TLV_NULL_PACKET, + TLV_NULL_PACKET, + TLV_NULL_PACKET, + TLV_NULL_PACKET); + ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + assertThat(new TlvExtractor().sniff(input)).isTrue(); + } + + @Test + public void sniff_onNonTlvStream_returnsFalse() throws Exception { + byte[] data = TestUtil.createByteArray(0x47, 0x40, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00); + ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + assertThat(new TlvExtractor().sniff(input)).isFalse(); + } + + @Test + public void sniff_onStreamWithUnknownPacketType_returnsFalse() throws Exception { + // Valid sync byte but an unknown packet_type (0x55) must not be treated as TLV. + byte[] data = TestUtil.createByteArray(0x7F, 0x55, 0x00, 0x00, 0x7F, 0x55, 0x00, 0x00); + ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + assertThat(new TlvExtractor().sniff(input)).isFalse(); + } + + /** + * Drives a header-compressed IP (context type 0x61) TLV packet that wraps an MMTP signaling + * packet carrying a PA message / MMT Package Table with a single HEVC asset, mirroring how NHK + * BS4K/BS8K deliver signaling, and verifies that a video track is created. + */ + @Test + public void read_compressedIpPacketWithMpt_createsVideoTrack() throws Exception { + // Signaling payload: signaling header + PA message + inlined MMT Package Table (hev1 asset). + byte[] signalingPayload = + TestUtil.createByteArray( + 0x00, 0x00, // Signaling payload header (FI=complete, A=0) + fragmentation_counter. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B, // PA: message_id, version, length=27. + 0x00, // number_of_tables=0 (tables inlined). + 0x20, 0x00, 0x00, 0x16, // MPT: table_id, version, length=22. + 0x00, 0x00, 0x00, 0x00, // reserved+mode, pkg_id_len=0, MPT_descriptors_length=0. + 0x01, // number_of_assets=1. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // identifier_type, asset_id_scheme, asset_id_length. + 0x68, 0x65, 0x76, 0x31, // asset_type="hev1". + 0x00, 0x01, // reserved+clock, location_count=1. + 0x00, 0x10, 0x01, // location_type=0x00, packet_id=0x1001. + 0x00, 0x00 // asset_descriptors_length=0. + ); + // MMTP packet header: version 0, type 0x02 (signaling), packet_id, timestamp, sequence number. + byte[] mmtpHeader = + TestUtil.createByteArray( + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00); + // Compressed-IP context identification header (CID+SN, context type 0x61) then the MMTP packet. + byte[] tlvPayload = + Bytes.concat(TestUtil.createByteArray(0x00, 0x00, 0x61), mmtpHeader, signalingPayload); + byte[] tlvHeader = + TestUtil.createByteArray(0x7F, 0x03, (tlvPayload.length >> 8) & 0xFF, tlvPayload.length & 0xFF); + byte[] data = Bytes.concat(tlvHeader, tlvPayload); + + FakeExtractorOutput output = new FakeExtractorOutput(); + TlvExtractor extractor = new TlvExtractor(); + extractor.init(output); + ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + PositionHolder seekPosition = new PositionHolder(); + int result = Extractor.RESULT_CONTINUE; + while (result != Extractor.RESULT_END_OF_INPUT) { + result = extractor.read(input, seekPosition); + } + + assertThat(output.numberOfTracks).isEqualTo(1); + assertThat(output.tracksEnded).isTrue(); + } +}