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);
}