Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2026 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.container;

import androidx.annotation.Nullable;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.util.UnstableApi;
import java.util.Arrays;

/**
* Stores the raw payload of a format-specific box so it can be re-written during transmuxing
* (stream copy) without re-encoding.
*
* <p>Some codecs store codec configuration in a container box whose bytes are not otherwise exposed
* on {@link Format} or {@link Format#initializationData} (for example the E-AC-3 {@code dec3} /
* EC3SpecificBox).
*
* <p>The {@link #data} holds the box body only, i.e. excluding the 4-byte size and 4-byte type
* header, and {@link #boxType} identifies which box the payload belongs to (for example {@code
* dec3}).
*/
@UnstableApi
public final class Mp4FormatSpecificMetadataEntry implements Metadata.Entry {

/** The four-character type of the box the {@link #data} belongs to, for example {@code dec3}. */
public final String boxType;

/** The raw box payload (the box body, excluding its 4-byte size and 4-byte type header). */
public final byte[] data;

/** Creates an instance. */
public Mp4FormatSpecificMetadataEntry(String boxType, byte[] data) {
this.boxType = boxType;
this.data = data.clone();
}

@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Mp4FormatSpecificMetadataEntry other = (Mp4FormatSpecificMetadataEntry) obj;
return boxType.equals(other.boxType) && Arrays.equals(data, other.data);
}

@Override
public int hashCode() {
return 31 * boxType.hashCode() + Arrays.hashCode(data);
}

@Override
public String toString() {
return "Mp4FormatSpecificMetadataEntry: boxType=" + boxType;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import androidx.media3.container.Mp4AlternateGroupData;
import androidx.media3.container.Mp4Box;
import androidx.media3.container.Mp4Box.LeafBox;
import androidx.media3.container.Mp4FormatSpecificMetadataEntry;
import androidx.media3.container.Mp4LocationData;
import androidx.media3.container.Mp4TimestampData;
import androidx.media3.container.NalUnitUtil;
Expand Down Expand Up @@ -2251,9 +2252,25 @@ private static void parseAudioSampleEntry(
out.format =
Ac3Util.parseAc3AnnexFFormat(parent, Integer.toString(trackId), language, drmInitData);
} else if (childAtomType == Mp4Box.TYPE_dec3) {
// Preserve the raw EC3SpecificBox body as format-specific transmuxing metadata so that
// downstream components (e.g. the muxer for stream-copy) can rewrite the dec3 box.
int childAtomBodySize = childAtomSize - Mp4Box.HEADER_SIZE;
byte[] ec3SpecificBoxPayload = new byte[childAtomBodySize];
parent.setPosition(Mp4Box.HEADER_SIZE + childPosition);
out.format =
parent.readBytes(ec3SpecificBoxPayload, /* offset= */ 0, childAtomBodySize);
parent.setPosition(Mp4Box.HEADER_SIZE + childPosition);
Format eac3Format =
Ac3Util.parseEAc3AnnexFFormat(parent, Integer.toString(trackId), language, drmInitData);
Metadata.Entry dec3TransmuxingData =
new Mp4FormatSpecificMetadataEntry("dec3", ec3SpecificBoxPayload);
out.format =
eac3Format
.buildUpon()
.setMetadata(
eac3Format.metadata != null
? eac3Format.metadata.copyWithAppendedEntries(dec3TransmuxingData)
: new Metadata(dec3TransmuxingData))
.build();
} else if (childAtomType == Mp4Box.TYPE_dac4) {
parent.setPosition(Mp4Box.HEADER_SIZE + childPosition);
out.format =
Expand Down
16 changes: 16 additions & 0 deletions libraries/muxer/src/main/java/androidx/media3/muxer/Boxes.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.container.MdtaMetadataEntry;
import androidx.media3.container.Mp4FormatSpecificMetadataEntry;
import androidx.media3.container.Mp4LocationData;
import androidx.media3.container.NalUnitUtil;
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
Expand Down Expand Up @@ -781,6 +782,18 @@ public static ByteBuffer codecSpecificBox(Format format) {
return dOpsBox(format);
case MimeTypes.AUDIO_IAMF:
return iacbBox(format);
case MimeTypes.AUDIO_E_AC3:
case MimeTypes.AUDIO_E_AC3_JOC:
@Nullable
Mp4FormatSpecificMetadataEntry dec3Entry =
format.metadata != null
? format.metadata.getFirstEntryOfType(Mp4FormatSpecificMetadataEntry.class)
: null;
byte[] dec3Payload =
dec3Entry != null && dec3Entry.boxType.equals("dec3") ? dec3Entry.data : null;
checkArgument(
dec3Payload != null && dec3Payload.length > 0, "dec3 payload not found for dec3 box.");
return BoxUtils.wrapIntoBox("dec3", ByteBuffer.wrap(dec3Payload));
case MimeTypes.AUDIO_RAW:
return ByteBuffer.allocate(0); // No codec specific box for raw audio.
case MimeTypes.VIDEO_H263:
Expand Down Expand Up @@ -1847,6 +1860,9 @@ private static String codecSpecificFourcc(Format format) {
return "Opus";
case MimeTypes.AUDIO_IAMF:
return "iamf";
case MimeTypes.AUDIO_E_AC3:
case MimeTypes.AUDIO_E_AC3_JOC:
return "ec-3";
case MimeTypes.AUDIO_RAW:
if (format.pcmEncoding == C.ENCODING_PCM_16BIT) {
return "sowt";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
* <li>Opus
* <li>Vorbis
* <li>Raw Audio
* <li>E-AC-3 (Dolby Digital Plus)
* <li>E-AC-3 JOC (Dolby Atmos)
* </ul>
* <li>Metadata
* </ul>
Expand Down Expand Up @@ -180,7 +182,9 @@ public FragmentedMp4Muxer build() {
MimeTypes.AUDIO_IAMF,
MimeTypes.AUDIO_OPUS,
MimeTypes.AUDIO_VORBIS,
MimeTypes.AUDIO_RAW);
MimeTypes.AUDIO_RAW,
MimeTypes.AUDIO_E_AC3,
MimeTypes.AUDIO_E_AC3_JOC);

// LINT.ThenChange(Boxes.java:codec_specific_boxes)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
* <li>Opus
* <li>Vorbis
* <li>Raw Audio
* <li>E-AC-3 (Dolby Digital Plus)
* <li>E-AC-3 JOC (Dolby Atmos)
* </ul>
* <li>Metadata
* </ul>
Expand Down Expand Up @@ -414,6 +416,8 @@ public Mp4Muxer build() {
MimeTypes.AUDIO_OPUS,
MimeTypes.AUDIO_VORBIS,
MimeTypes.AUDIO_RAW,
MimeTypes.AUDIO_E_AC3,
MimeTypes.AUDIO_E_AC3_JOC,
MimeTypes.AUDIO_IAMF);

// LINT.ThenChange(Boxes.java:codec_specific_boxes)
Expand Down
124 changes: 124 additions & 0 deletions libraries/muxer/src/test/java/androidx/media3/muxer/BoxesTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
import androidx.media3.common.C;
import androidx.media3.common.ColorInfo;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util;
import androidx.media3.container.MdtaMetadataEntry;
import androidx.media3.container.Mp4FormatSpecificMetadataEntry;
import androidx.media3.container.Mp4LocationData;
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
import androidx.media3.test.utils.DumpFileAsserts;
Expand Down Expand Up @@ -403,6 +405,128 @@ public void createAudioSampleEntryBox_forVorbis_matchesExpected() throws Excepti
MuxerTestUtil.getExpectedMp4DumpFilePath("audio_sample_entry_box_vorbis"));
}

@Test
public void createCodecSpecificBox_forEAc3_wrapsDec3PayloadVerbatim() {
byte[] payload = new byte[] {0x0F, (byte) 0x80, 0x00};
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3)
.setMetadata(new Metadata(new Mp4FormatSpecificMetadataEntry("dec3", payload)))
.build();

ByteBuffer box = Boxes.codecSpecificBox(format);

// Box layout: 4 bytes size + 4 bytes "dec3" type + payload bytes.
assertThat(box.remaining()).isEqualTo(8 + payload.length);
byte[] typeBytes = new byte[4];
box.position(4);
box.get(typeBytes);
assertThat(Util.fromUtf8Bytes(typeBytes)).isEqualTo("dec3");
byte[] actual = new byte[payload.length];
box.get(actual);
assertThat(actual).isEqualTo(payload);
}

@Test
public void createCodecSpecificBox_forEAc3Joc_wrapsDec3PayloadVerbatim() {
byte[] payload = new byte[] {0x0F, (byte) 0x80, 0x00};
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC)
.setMetadata(new Metadata(new Mp4FormatSpecificMetadataEntry("dec3", payload)))
.build();

ByteBuffer box = Boxes.codecSpecificBox(format);

assertThat(box.remaining()).isEqualTo(8 + payload.length);
byte[] typeBytes = new byte[4];
box.position(4);
box.get(typeBytes);
assertThat(Util.fromUtf8Bytes(typeBytes)).isEqualTo("dec3");
byte[] actual = new byte[payload.length];
box.get(actual);
assertThat(actual).isEqualTo(payload);
}

@Test
public void createCodecSpecificBox_forEAc3WithNoTransmuxingData_throws() {
// No Mp4FormatSpecificMetadataEntry entry on the format's metadata.
Format format = FAKE_AUDIO_FORMAT.buildUpon().setSampleMimeType(MimeTypes.AUDIO_E_AC3).build();

assertThrows(IllegalArgumentException.class, () -> Boxes.codecSpecificBox(format));
}

@Test
public void createCodecSpecificBox_forEAc3WithEmptyPayload_throws() {
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3)
.setMetadata(new Metadata(new Mp4FormatSpecificMetadataEntry("dec3", new byte[0])))
.build();

assertThrows(IllegalArgumentException.class, () -> Boxes.codecSpecificBox(format));
}

@Test
public void createCodecSpecificBox_forEAc3WithWrongBoxType_throws() {
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3)
.setMetadata(
new Metadata(
new Mp4FormatSpecificMetadataEntry(
"wrong", new byte[] {0x0F, (byte) 0x80, 0x00})))
.build();

assertThrows(IllegalArgumentException.class, () -> Boxes.codecSpecificBox(format));
}

@Test
public void createAudioSampleEntryBox_forEAc3_usesEc3Fourcc() {
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3)
.setMetadata(
new Metadata(
new Mp4FormatSpecificMetadataEntry(
"dec3", new byte[] {0x0F, (byte) 0x80, 0x00})))
.build();

ByteBuffer box = Boxes.audioSampleEntry(format);

// Sample entry box layout: 4 bytes size + 4 bytes fourcc + ...
byte[] fourccBytes = new byte[4];
box.position(4);
box.get(fourccBytes);
assertThat(Util.fromUtf8Bytes(fourccBytes)).isEqualTo("ec-3");
}

@Test
public void createAudioSampleEntryBox_forEAc3Joc_usesEc3Fourcc() {
Format format =
FAKE_AUDIO_FORMAT
.buildUpon()
.setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC)
.setMetadata(
new Metadata(
new Mp4FormatSpecificMetadataEntry(
"dec3", new byte[] {0x0F, (byte) 0x80, 0x00})))
.build();

ByteBuffer box = Boxes.audioSampleEntry(format);

// Sample entry box layout: 4 bytes size + 4 bytes fourcc + ...
byte[] fourccBytes = new byte[4];
box.position(4);
box.get(fourccBytes);
assertThat(Util.fromUtf8Bytes(fourccBytes)).isEqualTo("ec-3");
}

@Test
public void createAudioSampleEntryBox_withUnknownAudioFormat_throws() {
// The audio format contains an unknown MIME type.
Expand Down
Loading