diff --git a/libraries/container/src/main/java/androidx/media3/container/ObuParser.java b/libraries/container/src/main/java/androidx/media3/container/ObuParser.java index ada1bb0954..d3387a1910 100644 --- a/libraries/container/src/main/java/androidx/media3/container/ObuParser.java +++ b/libraries/container/src/main/java/androidx/media3/container/ObuParser.java @@ -43,6 +43,9 @@ public final class ObuParser { /** OBU type frame header. */ public static final int OBU_FRAME_HEADER = 3; + /** OBU type metadata. */ + public static final int OBU_METADATA = 5; + /** OBU type frame. */ public static final int OBU_FRAME = 6; @@ -136,6 +139,48 @@ public final class ObuParser { /** See {@code OrderHintBits}. */ public final int orderHintBits; + /** See {@code seq_profile}. */ + public final int seqProfile; + + /** See {@code seq_level_idx}. */ + public final int seqLevelIdx0; + + /** See {@code seq_tier}. */ + public final int seqTier0; + + /** See {@code initial_display_delay_present}. */ + public final boolean initialDisplayDelayPresentFlag; + + /** See {@code initial_display_delay_minus_one}. */ + public final int initialDisplayDelayMinus1; + + /** See {@code high_bitdepth}. */ + public final boolean highBitdepth; + + /** See {@code twelve_bit}. */ + public final boolean twelveBit; + + /** See {@code mono_chrome}. */ + public final boolean monochrome; + + /** See {@code subsampling_x}. */ + public final boolean subsamplingX; + + /** See {@code subsampling_Y}. */ + public final boolean subsamplingY; + + /** See {@code chroma_sample_position}. */ + public final int chromaSamplePosition; + + /** See {@code color_primaries}. */ + public final byte colorPrimaries; + + /** See {@code transfer_characteristics}. */ + public final byte transferCharacteristics; + + /** See {@code matrix_coefficients}. */ + public final byte matrixCoefficients; + /** * Returns a {@link SequenceHeader} parsed from the input OBU, or {@code null} if the AV1 * bitstream is not yet supported. @@ -153,38 +198,60 @@ public final class ObuParser { /** Parses a {@link #OBU_SEQUENCE_HEADER} and creates an instance. */ private SequenceHeader(Obu obu) throws NotYetImplementedException { + int seqLevelIdx0 = 0; + int seqTier0 = 0; + int initialDisplayDelayMinus1 = 0; checkArgument(obu.type == OBU_SEQUENCE_HEADER); byte[] data = new byte[obu.payload.remaining()]; // Do not modify obu.payload while reading it. obu.payload.asReadOnlyBuffer().get(data); ParsableBitArray obuData = new ParsableBitArray(data); - obuData.skipBits(4); // seq_profile and still_picture + seqProfile = obuData.readBits(3); + obuData.skipBit(); // still_picture reducedStillPictureHeader = obuData.readBit(); - throwWhenFeatureRequired(reducedStillPictureHeader); - boolean timingInfoPresentFlag = obuData.readBit(); - if (timingInfoPresentFlag) { - skipTimingInfo(obuData); - decoderModelInfoPresentFlag = obuData.readBit(); - if (decoderModelInfoPresentFlag) { - // skip decoder_model_info() - obuData.skipBits(47); - } - } else { + if (reducedStillPictureHeader) { + seqLevelIdx0 = obuData.readBits(5); decoderModelInfoPresentFlag = false; - } - boolean initialDisplayDelayPresentFlag = obuData.readBit(); - int operatingPointsCntMinus1 = obuData.readBits(5); - for (int i = 0; i <= operatingPointsCntMinus1; i++) { - obuData.skipBits(12); // operating_point_idc[ i ] - int seqLevelIdx = obuData.readBits(5); - if (seqLevelIdx > 7) { - obuData.skipBit(); // seq_tier[ i ] + initialDisplayDelayPresentFlag = false; + } else { + boolean timingInfoPresentFlag = obuData.readBit(); + if (timingInfoPresentFlag) { + skipTimingInfo(obuData); + decoderModelInfoPresentFlag = obuData.readBit(); + if (decoderModelInfoPresentFlag) { + // skip decoder_model_info() + obuData.skipBits(47); + } + } else { + decoderModelInfoPresentFlag = false; } - throwWhenFeatureRequired(decoderModelInfoPresentFlag); - if (initialDisplayDelayPresentFlag) { - boolean initialDisplayDelayPresentForThisOpFlag = obuData.readBit(); - if (initialDisplayDelayPresentForThisOpFlag) { - obuData.skipBits(4); // initial_display_delay_minus_1[ i ] + initialDisplayDelayPresentFlag = obuData.readBit(); + int operatingPointsCntMinus1 = obuData.readBits(5); + for (int i = 0; i <= operatingPointsCntMinus1; i++) { + obuData.skipBits(12); // operating_point_idc[ i ] + if (i == 0) { + seqLevelIdx0 = obuData.readBits(5); + if (seqLevelIdx0 > 7) { + seqTier0 = obuData.readBit() ? 1 : 0; + } + } else { + int seqLevelIdx = obuData.readBits(5); + if (seqLevelIdx > 7) { + obuData.skipBit(); // seq_tier[ i ] + } + } + if (decoderModelInfoPresentFlag) { + obuData.skipBit(); // decoder_model_present_for_this_op + } + if (initialDisplayDelayPresentFlag) { + boolean initialDisplayDelayPresentForThisOpFlag = obuData.readBit(); + if (initialDisplayDelayPresentForThisOpFlag) { + if (i == 0) { + initialDisplayDelayMinus1 = obuData.readBits(4); + } else { + obuData.skipBits(4); // initial_display_delay_minus_1[ i ] + } + } } } } @@ -192,39 +259,119 @@ public final class ObuParser { int frameHeightBitsMinus1 = obuData.readBits(4); obuData.skipBits(frameWidthBitsMinus1 + 1); // max_frame_width_minus_1 obuData.skipBits(frameHeightBitsMinus1 + 1); // max_frame_height_minus_1 - frameIdNumbersPresentFlag = obuData.readBit(); - throwWhenFeatureRequired(frameIdNumbersPresentFlag); + if (!reducedStillPictureHeader) { + frameIdNumbersPresentFlag = obuData.readBit(); + } else { + frameIdNumbersPresentFlag = false; + } + if (frameIdNumbersPresentFlag) { + obuData.skipBits(4); // delta_frame_id_length_minus_2 + obuData.skipBits(3); // additional_frame_id_length_minus_1 + } // use_128x128_superblock, enable_filter_intra, and enable_intra_edge_filter obuData.skipBits(3); - // enable_interintra_compound, enable_masked_compound, enable_warped_motion, and - // enable_dual_filter - obuData.skipBits(4); - boolean enableOrderHint = obuData.readBit(); - if (enableOrderHint) { - obuData.skipBits(2); // enable_jnt_comp and enable_ref_frame_mvs - } - boolean seqChooseScreenContentTools = obuData.readBit(); - if (seqChooseScreenContentTools) { - seqForceScreenContentTools = true; - } else { - seqForceScreenContentTools = obuData.readBit(); - } - if (seqForceScreenContentTools) { - boolean seqChooseIntegerMv = obuData.readBit(); - if (seqChooseIntegerMv) { - seqForceIntegerMv = true; - } else { - seqForceIntegerMv = obuData.readBit(); - } - } else { + if (reducedStillPictureHeader) { seqForceIntegerMv = true; - } - if (enableOrderHint) { - int orderHintBitsMinus1 = obuData.readBits(3); - orderHintBits = orderHintBitsMinus1 + 1; - } else { + seqForceScreenContentTools = true; orderHintBits = 0; + } else { + // enable_interintra_compound, enable_masked_compound, enable_warped_motion, and + // enable_dual_filter + obuData.skipBits(4); + boolean enableOrderHint = obuData.readBit(); + if (enableOrderHint) { + obuData.skipBits(2); // enable_jnt_comp and enable_ref_frame_mvs + } + boolean seqChooseScreenContentTools = obuData.readBit(); + if (seqChooseScreenContentTools) { + seqForceScreenContentTools = true; + } else { + seqForceScreenContentTools = obuData.readBit(); + } + if (seqForceScreenContentTools) { + boolean seqChooseIntegerMv = obuData.readBit(); + if (seqChooseIntegerMv) { + seqForceIntegerMv = true; + } else { + seqForceIntegerMv = obuData.readBit(); + } + } else { + seqForceIntegerMv = true; + } + if (enableOrderHint) { + int orderHintBitsMinus1 = obuData.readBits(3); + orderHintBits = orderHintBitsMinus1 + 1; + } else { + orderHintBits = 0; + } } + this.seqLevelIdx0 = seqLevelIdx0; + this.seqTier0 = seqTier0; + this.initialDisplayDelayMinus1 = initialDisplayDelayMinus1; + // enable_superres, enable_cdef, enable_restoration + obuData.skipBits(3); + // Begin Color Config + highBitdepth = obuData.readBit(); + if (seqProfile == 2 && highBitdepth) { + twelveBit = obuData.readBit(); + } else { + twelveBit = false; + } + if (seqProfile != 1) { + monochrome = obuData.readBit(); + } else { + monochrome = false; + } + boolean colorDescriptionPresent = obuData.readBit(); + if (colorDescriptionPresent) { + colorPrimaries = (byte) obuData.readBits(8); + transferCharacteristics = (byte) obuData.readBits(8); + matrixCoefficients = (byte) obuData.readBits(8); + } else { + colorPrimaries = 0; + transferCharacteristics = 0; + matrixCoefficients = 0; + } + if (monochrome) { + obuData.skipBit(); // color_range + subsamplingX = false; + subsamplingY = false; + chromaSamplePosition = 0; + } else if (colorPrimaries == 0x1 /* CP_BT_709 */ + && transferCharacteristics == 13 /* TC_SRGB */ + && matrixCoefficients == 0x0 /* MC_IDENTITY */) { + // Nothing to read from obu. + subsamplingX = false; + subsamplingY = false; + chromaSamplePosition = 0; + } else { + obuData.skipBit(); // color_range + if (seqProfile == 0) { + subsamplingX = true; + subsamplingY = true; + } else if (seqProfile == 1) { + subsamplingX = false; + subsamplingY = false; + } else { + if (twelveBit) { + subsamplingX = obuData.readBit(); + if (subsamplingX) { + subsamplingY = obuData.readBit(); + } else { + subsamplingY = false; + } + } else { + subsamplingX = true; + subsamplingY = false; + } + } + if (subsamplingX && subsamplingY) { + chromaSamplePosition = obuData.readBits(2); + } else { + chromaSamplePosition = 0; + } + } + obuData.skipBit(); // separate_uv_delta_q } /** Advances the bit array by skipping the {@code timing_info()} syntax element. */ diff --git a/libraries/container/src/test/java/androidx/media3/container/ObuParserTest.java b/libraries/container/src/test/java/androidx/media3/container/ObuParserTest.java index a0af73aa57..40eca5a113 100644 --- a/libraries/container/src/test/java/androidx/media3/container/ObuParserTest.java +++ b/libraries/container/src/test/java/androidx/media3/container/ObuParserTest.java @@ -87,6 +87,20 @@ public class ObuParserTest { assertThat(sequenceHeader.seqForceScreenContentTools).isTrue(); assertThat(sequenceHeader.seqForceIntegerMv).isTrue(); assertThat(sequenceHeader.orderHintBits).isEqualTo(7); + assertThat(sequenceHeader.seqProfile).isEqualTo(0); + assertThat(sequenceHeader.seqLevelIdx0).isEqualTo(4); + assertThat(sequenceHeader.seqTier0).isEqualTo(0); + assertThat(sequenceHeader.initialDisplayDelayPresentFlag).isFalse(); + assertThat(sequenceHeader.initialDisplayDelayMinus1).isEqualTo(0); + assertThat(sequenceHeader.highBitdepth).isFalse(); + assertThat(sequenceHeader.twelveBit).isFalse(); + assertThat(sequenceHeader.monochrome).isFalse(); + assertThat(sequenceHeader.subsamplingX).isTrue(); + assertThat(sequenceHeader.subsamplingY).isTrue(); + assertThat(sequenceHeader.chromaSamplePosition).isEqualTo(0); + assertThat(sequenceHeader.colorPrimaries).isEqualTo(1); + assertThat(sequenceHeader.transferCharacteristics).isEqualTo(1); + assertThat(sequenceHeader.matrixCoefficients).isEqualTo(1); } @Test diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Av1ConfigUtil.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Av1ConfigUtil.java new file mode 100644 index 0000000000..9f1ecd07cf --- /dev/null +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Av1ConfigUtil.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2025 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.muxer; + +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.muxer.BoxUtils.concatenateBuffers; + +import androidx.annotation.Nullable; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.container.ObuParser; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility methods for generating AV1 initialization data. + * + *

AV1 Bitstream and Decoding Process + * Specification + */ +@UnstableApi +/* package */ final class Av1ConfigUtil { + + private static final int MAX_LEB128_SIZE_BYTES = 8; + private static final int MAX_HEADER_AND_LENGTH_SIZE_BYTES = 1 + MAX_LEB128_SIZE_BYTES; + // AV1 configuration record size without config obus. + private static final int MAX_AV1_CONFIG_RECORD_SIZE_BYTES = 4; + // Only the last sample is allowed to have obu_has_size_field == 0. + private static final int OBU_HAS_SIZE_FIELD_BYTES = 1 << 1; + + /** + * Generates AV1 initialization data from the first sample {@link ByteBuffer}. + * + * @param byteBuffer The first sample data, in the format specified by the AV1 Codec ISO Media File + * Format Binding. + * @return The initialization data. + */ + public static byte[] createAv1CodecConfigurationRecord(ByteBuffer byteBuffer) { + @Nullable ByteBuffer csdHeader = null; + @Nullable ByteBuffer configSequenceObu = null; + List configMetadataObusList = new ArrayList<>(); + + List obus = ObuParser.split(byteBuffer); + + for (ObuParser.Obu obu : obus) { + if (obu.type == ObuParser.OBU_METADATA) { + configMetadataObusList.add(getConfigObuWithHeaderAndLength(obu)); + } else if (obu.type == ObuParser.OBU_SEQUENCE_HEADER && configSequenceObu == null) { + configSequenceObu = getConfigObuWithHeaderAndLength(obu); + csdHeader = parseConfigFromSeqHeader(obu); + } + } + checkNotNull(configSequenceObu, "No sequence header available."); + ByteBuffer configMetadataObus = + concatenateBuffers(configMetadataObusList.toArray(new ByteBuffer[0])); + ByteBuffer configObus = configSequenceObu; + if (configMetadataObus != null) { + configObus = concatenateBuffers(configObus, configMetadataObus); + } + + return concatenateBuffers(checkNotNull(csdHeader, "csdHeader is null."), configObus).array(); + } + + private static ByteBuffer getConfigObuWithHeaderAndLength(ObuParser.Obu obu) { + ByteBuffer configObu = + ByteBuffer.allocate(MAX_HEADER_AND_LENGTH_SIZE_BYTES + obu.payload.remaining()); + configObu.put(obuHeader(obu.type)); + configObu.put(lebEncode(obu.payload.remaining())); + configObu.put(obu.payload.duplicate()); + configObu.flip(); + return configObu; + } + + // Obu header byte - https://aomediacodec.github.io/av1-spec/#obu-header-syntax. + private static byte obuHeader(int obuType) { + return (byte) (obuType << 3 | OBU_HAS_SIZE_FIELD_BYTES); + } + + private static ByteBuffer lebEncode(int value) { + checkArgument(value > 0); + int lebSize = lebSizeInBytes(value); + ByteBuffer sizeBytes = ByteBuffer.allocate(lebSize); + checkState(lebSize < MAX_LEB128_SIZE_BYTES); + for (int i = 0; i < lebSize; ++i) { + int byteValue = (byte) (value & 0x7f); + value >>= 7; + if (value != 0) { + byteValue |= 0x80; // signal that more bytes follow. + } + sizeBytes.put((byte) byteValue); + } + sizeBytes.flip(); + return sizeBytes; + } + + private static int lebSizeInBytes(int value) { + int size = 0; + do { + size++; + value >>= 7; + } while (value != 0); + return size; + } + + private static ByteBuffer parseConfigFromSeqHeader(ObuParser.Obu obu) { + ByteBuffer csd = ByteBuffer.allocate(MAX_AV1_CONFIG_RECORD_SIZE_BYTES); + csd.put((byte) (0x1 << 7 | 0x1)); // Marker(0x1) << 7 | Version (0x1) + + @Nullable ObuParser.SequenceHeader sequenceHeader = ObuParser.SequenceHeader.parse(obu); + checkNotNull(sequenceHeader, "No sequence header available."); + csd.put((byte) (sequenceHeader.seqProfile << 5 | sequenceHeader.seqLevelIdx0)); + csd.put( + (byte) + ((sequenceHeader.seqTier0 > 0 ? 0x80 : 0x0) + | (sequenceHeader.highBitdepth ? 0x40 : 0x0) + | (sequenceHeader.twelveBit ? 0x20 : 0x0) + | (sequenceHeader.monochrome ? 0x10 : 0x0) + | (sequenceHeader.subsamplingX ? 0x08 : 0x0) + | (sequenceHeader.subsamplingY ? 0x04 : 0x0) + | sequenceHeader.chromaSamplePosition)); + csd.put( + (byte) + ((sequenceHeader.initialDisplayDelayPresentFlag ? 0x10 : 0x0) + | (sequenceHeader.initialDisplayDelayPresentFlag + ? sequenceHeader.initialDisplayDelayMinus1 & 0x0F + : 0x0))); + csd.flip(); + return csd; + } + + private Av1ConfigUtil() {} +} diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Boxes.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Boxes.java index 29ab42fe09..12cb4c79bc 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/Boxes.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Boxes.java @@ -50,6 +50,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; import org.checkerframework.checker.nullness.qual.PolyNull; /** Writes out various types of boxes as per MP4 (ISO/IEC 14496-12) standards. */ @@ -145,6 +146,14 @@ import org.checkerframework.checker.nullness.qual.PolyNull; continue; } Format format = track.format; + if (Objects.equals(track.format.sampleMimeType, MimeTypes.VIDEO_AV1) + && format.initializationData.isEmpty()) { + format = + format + .buildUpon() + .setInitializationData(ImmutableList.of(checkNotNull(track.parsedCsd))) + .build(); + } String languageCode = bcp47LanguageTagToIso3(format.language); // Generate the sample durations to calculate the total duration for tkhd box. @@ -1533,11 +1542,7 @@ import org.checkerframework.checker.nullness.qual.PolyNull; /** Returns the av1C box. */ private static ByteBuffer av1CBox(Format format) { // For AV1, the entire codec-specific box is packed into csd-0. - checkArgument( - !format.initializationData.isEmpty(), "csd-0 is not found in the format for av1C box"); - byte[] csd0 = format.initializationData.get(0); - checkArgument(csd0.length > 0, "csd-0 is empty for av1C box."); return BoxUtils.wrapIntoBox("av1C", ByteBuffer.wrap(csd0)); } diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/FragmentedMp4Writer.java b/libraries/muxer/src/main/java/androidx/media3/muxer/FragmentedMp4Writer.java index 3acc26c9a6..ae2f7752f0 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/FragmentedMp4Writer.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/FragmentedMp4Writer.java @@ -19,6 +19,7 @@ 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.muxer.AnnexBUtils.doesSampleContainAnnexBNalUnits; +import static androidx.media3.muxer.Av1ConfigUtil.createAv1CodecConfigurationRecord; import static androidx.media3.muxer.Boxes.BOX_HEADER_SIZE; import static androidx.media3.muxer.Boxes.MFHD_BOX_CONTENT_SIZE; import static androidx.media3.muxer.Boxes.TFHD_BOX_CONTENT_SIZE; @@ -40,6 +41,7 @@ import java.nio.channels.Channels; import java.nio.channels.WritableByteChannel; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -165,6 +167,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public void writeSampleData(Track track, ByteBuffer byteBuffer, BufferInfo bufferInfo) throws IOException { + if (Objects.equals(track.format.sampleMimeType, MimeTypes.VIDEO_AV1) + && track.format.initializationData.isEmpty() + && track.parsedCsd == null) { + track.parsedCsd = createAv1CodecConfigurationRecord(byteBuffer.duplicate()); + } if (!headerCreated) { createHeader(); headerCreated = true; diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java index b8dec32424..9d133a64f3 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java @@ -18,6 +18,7 @@ package androidx.media3.muxer; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.muxer.AnnexBUtils.doesSampleContainAnnexBNalUnits; +import static androidx.media3.muxer.Av1ConfigUtil.createAv1CodecConfigurationRecord; import static androidx.media3.muxer.Boxes.BOX_HEADER_SIZE; import static androidx.media3.muxer.Boxes.LARGE_SIZE_BOX_HEADER_SIZE; import static androidx.media3.muxer.Boxes.getAxteBoxHeader; @@ -28,6 +29,7 @@ import static java.lang.Math.max; import static java.lang.Math.min; import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Util; import androidx.media3.container.MdtaMetadataEntry; import com.google.common.collect.Range; @@ -37,6 +39,7 @@ import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; /** Writes all media samples into a single mdat box. */ @@ -151,6 +154,11 @@ import java.util.concurrent.atomic.AtomicBoolean; */ public void writeSampleData(Track track, ByteBuffer byteBuffer, BufferInfo bufferInfo) throws IOException { + if (Objects.equals(track.format.sampleMimeType, MimeTypes.VIDEO_AV1) + && track.format.initializationData.isEmpty() + && track.parsedCsd == null) { + track.parsedCsd = createAv1CodecConfigurationRecord(byteBuffer.duplicate()); + } track.writeSampleData(byteBuffer, bufferInfo); if (sampleBatchingEnabled) { doInterleave(); diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Track.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Track.java index 2da0b84d85..e8571b5751 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/Track.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Track.java @@ -17,6 +17,7 @@ package androidx.media3.muxer; import static androidx.media3.common.util.Assertions.checkArgument; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; @@ -37,6 +38,7 @@ import java.util.List; public final Deque pendingSamplesBufferInfo; public final Deque pendingSamplesByteBuffer; public boolean hadKeyframe; + @Nullable public byte[] parsedCsd; public long endOfStreamTimestampUs; private final boolean sampleCopyEnabled; diff --git a/libraries/muxer/src/test/java/androidx/media3/muxer/Av1ConfigUtilTest.java b/libraries/muxer/src/test/java/androidx/media3/muxer/Av1ConfigUtilTest.java new file mode 100644 index 0000000000..2ee596b182 --- /dev/null +++ b/libraries/muxer/src/test/java/androidx/media3/muxer/Av1ConfigUtilTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 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 + * + * https://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.muxer; + +import static androidx.media3.muxer.Av1ConfigUtil.createAv1CodecConfigurationRecord; +import static androidx.media3.test.utils.TestUtil.createByteArray; +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.nio.ByteBuffer; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link Av1ConfigUtil} */ +@RunWith(AndroidJUnit4.class) +public class Av1ConfigUtilTest { + + private static final ByteBuffer SEQUENCE_HEADER_AND_METADATA = + ByteBuffer.wrap( + createByteArray( + 0x0A, 0x0E, 0x00, 0x00, 0x00, 0x24, 0xC6, 0xAB, 0xDF, 0x3E, 0xFE, 0x24, 0x04, 0x04, + 0x04, 0x10, 0x2A, 0x11, 0x0E, 0x10, 0x00, 0xC8, 0xC6, 0x00, 0x00, 0x0C, 0x00, 0x00, + 0x00, 0x12, 0x03, 0xCE, 0x0A, 0x50, 0x24)); + private static final ByteBuffer METADATA_AND_SEQUENCE_HEADER = + ByteBuffer.wrap( + createByteArray( + 0x2A, 0x11, 0x0E, 0x10, 0x00, 0xC8, 0xC6, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x12, + 0x03, 0xCE, 0x0A, 0x50, 0x24, 0x0A, 0x0E, 0x00, 0x00, 0x00, 0x24, 0xC6, 0xAB, 0xDF, + 0x3E, 0xFE, 0x24, 0x04, 0x04, 0x04, 0x10)); + private static final ByteBuffer SEQUENCE_HEADER_YUV444 = + ByteBuffer.wrap( + createByteArray( + 0x0A, 0x0B, 0x20, 0x00, 0x00, 0x2D, 0x3D, 0xAB, 0xDF, 0x3E, 0xFE, 0x24, 0x04)); + + @Test + public void createAv1ConfigRecord_withSequenceHeaderAndMetaData_matchesExpected() { + byte[] initializationData = createAv1CodecConfigurationRecord(SEQUENCE_HEADER_AND_METADATA); + + byte[] expectedInitializationData = { + -127, 4, 12, 0, 10, 14, 0, 0, 0, 36, -58, -85, -33, 62, -2, 36, 4, 4, 4, 16, 42, 17, 14, 16, + 0, -56, -58, 0, 0, 12, 0, 0, 0, 18, 3, -50, 10, 80, 36 + }; + assertThat(initializationData).isEqualTo(expectedInitializationData); + } + + @Test + public void createAv1ConfigRecord_withMetaDataAndSequenceHeader_matchesExpected() { + byte[] initializationData = createAv1CodecConfigurationRecord(METADATA_AND_SEQUENCE_HEADER); + + byte[] expectedInitializationData = { + -127, 4, 12, 0, 10, 14, 0, 0, 0, 36, -58, -85, -33, 62, -2, 36, 4, 4, 4, 16, 42, 17, 14, 16, + 0, -56, -58, 0, 0, 12, 0, 0, 0, 18, 3, -50, 10, 80, 36 + }; + assertThat(initializationData).isEqualTo(expectedInitializationData); + } + + @Test + public void createAv1ConfigRecord_withSequenceHeaderYuv444_matchesExpected() { + byte[] initializationData = createAv1CodecConfigurationRecord(SEQUENCE_HEADER_YUV444); + + byte[] expectedInitializationData = { + -127, 37, 0, 0, 10, 11, 32, 0, 0, 45, 61, -85, -33, 62, -2, 36, 4 + }; + assertThat(initializationData).isEqualTo(expectedInitializationData); + } +} diff --git a/libraries/muxer/src/test/java/androidx/media3/muxer/FragmentedMp4MuxerEndToEndTest.java b/libraries/muxer/src/test/java/androidx/media3/muxer/FragmentedMp4MuxerEndToEndTest.java index b005405cba..4124d15412 100644 --- a/libraries/muxer/src/test/java/androidx/media3/muxer/FragmentedMp4MuxerEndToEndTest.java +++ b/libraries/muxer/src/test/java/androidx/media3/muxer/FragmentedMp4MuxerEndToEndTest.java @@ -22,10 +22,13 @@ import static androidx.media3.muxer.MuxerTestUtil.MP4_FILE_ASSET_DIRECTORY; import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.MediaFormatUtil; import androidx.media3.container.Mp4TimestampData; import androidx.media3.exoplayer.MediaExtractorCompat; import androidx.media3.extractor.mp4.FragmentedMp4Extractor; +import androidx.media3.extractor.text.DefaultSubtitleParserFactory; import androidx.media3.test.utils.DumpFileAsserts; import androidx.media3.test.utils.DumpableMp4Box; import androidx.media3.test.utils.FakeExtractorOutput; @@ -199,17 +202,57 @@ public class FragmentedMp4MuxerEndToEndTest { MuxerTestUtil.getExpectedDumpFilePath(AUDIO_ONLY_MP4 + "_fragmented_box_structure")); } + @Test + public void createAv1FragmentedMp4File_withoutCsd_matchesExpected() throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + FragmentedMp4Muxer mp4Muxer = + new FragmentedMp4Muxer.Builder(new FileOutputStream(outputFilePath)).build(); + + try { + mp4Muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 100_000_000L, + /* modificationTimestampSeconds= */ 500_000_000L)); + feedInputDataToMuxer(context, mp4Muxer, AV1_MP4, /* removeInitializationData= */ true); + } finally { + if (mp4Muxer != null) { + mp4Muxer.close(); + } + } + + FakeExtractorOutput fakeExtractorOutput = + TestUtil.extractAllSamplesFromFilePath( + new FragmentedMp4Extractor(new DefaultSubtitleParserFactory()), + checkNotNull(outputFilePath)); + DumpFileAsserts.assertOutput( + context, + fakeExtractorOutput, + MuxerTestUtil.getExpectedDumpFilePath(AV1_MP4 + "_fragmented")); + } + private static void feedInputDataToMuxer( Context context, FragmentedMp4Muxer muxer, String inputFileName) throws IOException, MuxerException { + feedInputDataToMuxer(context, muxer, inputFileName, /* removeInitializationData= */ false); + } + + private static void feedInputDataToMuxer( + Context context, + FragmentedMp4Muxer muxer, + String inputFileName, + boolean removeInitializationData) + throws IOException, MuxerException { MediaExtractorCompat extractor = new MediaExtractorCompat(context); Uri fileUri = Uri.parse(MP4_FILE_ASSET_DIRECTORY + inputFileName); extractor.setDataSource(fileUri, /* offset= */ 0); List addedTracks = new ArrayList<>(); for (int i = 0; i < extractor.getTrackCount(); i++) { - int trackId = - muxer.addTrack(MediaFormatUtil.createFormatFromMediaFormat(extractor.getTrackFormat(i))); + Format format = MediaFormatUtil.createFormatFromMediaFormat(extractor.getTrackFormat(i)); + if (removeInitializationData && MimeTypes.isVideo(format.sampleMimeType)) { + format = format.buildUpon().setInitializationData(null).build(); + } + int trackId = muxer.addTrack(format); addedTracks.add(trackId); extractor.selectTrack(i); } diff --git a/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java b/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java index e1553635fa..d9f315f74a 100644 --- a/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java +++ b/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java @@ -54,6 +54,7 @@ public class Mp4MuxerEndToEndTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); private static final String H265_HDR10_MP4 = "hdr10-720p.mp4"; + private static final String AV1_MP4 = "sample_av1.mp4"; private final Context context = ApplicationProvider.getApplicationContext(); @Test @@ -118,6 +119,30 @@ public class Mp4MuxerEndToEndTest { assertThat(outputFileBytes).isEmpty(); } + @Test + public void createAv1Mp4File_withoutCsd_matchesExpected() throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + Mp4Muxer mp4Muxer = new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)).build(); + + try { + mp4Muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 100_000_000L, + /* modificationTimestampSeconds= */ 500_000_000L)); + feedInputDataToMp4Muxer(context, mp4Muxer, AV1_MP4, /* removeInitializationData= */ true); + } finally { + if (mp4Muxer != null) { + mp4Muxer.close(); + } + } + + FakeExtractorOutput fakeExtractorOutput = + TestUtil.extractAllSamplesFromFilePath( + new Mp4Extractor(new DefaultSubtitleParserFactory()), checkNotNull(outputFilePath)); + DumpFileAsserts.assertOutput( + context, fakeExtractorOutput, MuxerTestUtil.getExpectedDumpFilePath(AV1_MP4)); + } + @Test public void createMp4File_muxerNotClosed_createsPartiallyWrittenValidFile() throws Exception { String outputPath = temporaryFolder.newFile().getPath(); diff --git a/libraries/muxer/src/test/java/androidx/media3/muxer/MuxerTestUtil.java b/libraries/muxer/src/test/java/androidx/media3/muxer/MuxerTestUtil.java index 12546670af..8074a3acab 100644 --- a/libraries/muxer/src/test/java/androidx/media3/muxer/MuxerTestUtil.java +++ b/libraries/muxer/src/test/java/androidx/media3/muxer/MuxerTestUtil.java @@ -24,6 +24,7 @@ import android.net.Uri; import android.util.Pair; import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.MediaFormatUtil; import androidx.media3.exoplayer.MediaExtractorCompat; import com.google.common.collect.ImmutableList; @@ -81,14 +82,23 @@ import java.util.List; public static void feedInputDataToMp4Muxer(Context context, Mp4Muxer muxer, String inputFileName) throws IOException, MuxerException { + feedInputDataToMp4Muxer(context, muxer, inputFileName, /* removeInitializationData= */ false); + } + + public static void feedInputDataToMp4Muxer( + Context context, Mp4Muxer muxer, String inputFileName, boolean removeInitializationData) + throws IOException, MuxerException { MediaExtractorCompat extractor = new MediaExtractorCompat(context); Uri fileUri = Uri.parse(MP4_FILE_ASSET_DIRECTORY + inputFileName); extractor.setDataSource(fileUri, /* offset= */ 0); List addedTracks = new ArrayList<>(); for (int i = 0; i < extractor.getTrackCount(); i++) { - int trackId = - muxer.addTrack(MediaFormatUtil.createFormatFromMediaFormat(extractor.getTrackFormat(i))); + Format format = MediaFormatUtil.createFormatFromMediaFormat(extractor.getTrackFormat(i)); + if (removeInitializationData && MimeTypes.isVideo(format.sampleMimeType)) { + format = format.buildUpon().setInitializationData(null).build(); + } + int trackId = muxer.addTrack(format); addedTracks.add(trackId); extractor.selectTrack(i); }