Implement fragmented MP4 (fMP4) in the Mp4Muxer

Changes includes;
1. Public API to enable fMP4 and to pass fragment duration.
2. Added `FragmentedMp4Writer`.
3. Added logic to create fragments based on given fragment duration.
4. Write "moov" box only once in the beginning.
3. Add all the required boxes for current implementation.
4. Unit tests for all the new boxes.
5. E2E test for generating fMP4.

Note: The output file is un seek-able with this first implementation.
PiperOrigin-RevId: 594426486
This commit is contained in:
sheenachhabra 2023-12-29 03:36:49 -08:00 committed by Copybara-Service
parent b0e00a7d28
commit e0257f403f
14 changed files with 1995 additions and 100 deletions

View File

@ -20,9 +20,12 @@ import static androidx.media3.common.util.Assertions.checkNotNull;
import android.content.Context; import android.content.Context;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.media.MediaExtractor; import android.media.MediaExtractor;
import androidx.annotation.Nullable;
import androidx.media3.common.util.MediaFormatUtil; import androidx.media3.common.util.MediaFormatUtil;
import androidx.media3.extractor.mp4.FragmentedMp4Extractor;
import androidx.media3.extractor.mp4.Mp4Extractor; import androidx.media3.extractor.mp4.Mp4Extractor;
import androidx.media3.test.utils.DumpFileAsserts; import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.DumpableMp4Box;
import androidx.media3.test.utils.FakeExtractorOutput; import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@ -77,7 +80,7 @@ public class Mp4MuxerEndToEndTest {
@Test @Test
public void createMp4File_fromInputFileSampleData_matchesExpected() throws IOException { public void createMp4File_fromInputFileSampleData_matchesExpected() throws IOException {
Mp4Muxer mp4Muxer = null; @Nullable Mp4Muxer mp4Muxer = null;
try { try {
mp4Muxer = new Mp4Muxer.Builder(checkNotNull(outputStream)).build(); mp4Muxer = new Mp4Muxer.Builder(checkNotNull(outputStream)).build();
@ -114,6 +117,55 @@ public class Mp4MuxerEndToEndTest {
AndroidMuxerTestUtil.getExpectedDumpFilePath("partial_" + H265_HDR10_MP4)); AndroidMuxerTestUtil.getExpectedDumpFilePath("partial_" + H265_HDR10_MP4));
} }
@Test
public void createFragmentedMp4File_fromInputFileSampleData_matchesExpected() throws IOException {
@Nullable Mp4Muxer mp4Muxer = null;
try {
mp4Muxer =
new Mp4Muxer.Builder(checkNotNull(outputStream)).setFragmentedMp4Enabled(true).build();
mp4Muxer.setModificationTime(/* timestampMs= */ 500_000_000L);
feedInputDataToMuxer(mp4Muxer, H265_HDR10_MP4);
} finally {
if (mp4Muxer != null) {
mp4Muxer.close();
}
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new FragmentedMp4Extractor(), checkNotNull(outputPath));
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
AndroidMuxerTestUtil.getExpectedDumpFilePath(H265_HDR10_MP4 + "_fragmented"));
}
@Test
public void createFragmentedMp4File_fromInputFileSampleData_matchesExpectedBoxStructure()
throws IOException {
@Nullable Mp4Muxer mp4Muxer = null;
try {
mp4Muxer =
new Mp4Muxer.Builder(checkNotNull(outputStream)).setFragmentedMp4Enabled(true).build();
mp4Muxer.setModificationTime(/* timestampMs= */ 500_000_000L);
feedInputDataToMuxer(mp4Muxer, H265_HDR10_MP4);
} finally {
if (mp4Muxer != null) {
mp4Muxer.close();
}
}
DumpableMp4Box dumpableMp4Box =
new DumpableMp4Box(
ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(checkNotNull(outputPath))));
DumpFileAsserts.assertOutput(
context,
dumpableMp4Box,
AndroidMuxerTestUtil.getExpectedDumpFilePath(H265_HDR10_MP4 + "_fragmented_box_structure"));
}
private void feedInputDataToMuxer(Mp4Muxer mp4Muxer, String inputFileName) throws IOException { private void feedInputDataToMuxer(Mp4Muxer mp4Muxer, String inputFileName) throws IOException {
MediaExtractor extractor = new MediaExtractor(); MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource( extractor.setDataSource(

View File

@ -31,6 +31,7 @@ import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.container.NalUnitUtil; import androidx.media3.container.NalUnitUtil;
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.primitives.Bytes; import com.google.common.primitives.Bytes;
@ -48,6 +49,13 @@ import java.util.Locale;
* buffers}. * buffers}.
*/ */
/* package */ final class Boxes { /* package */ final class Boxes {
private static final int BYTES_PER_INTEGER = 4;
// unsigned int(2) sample_depends_on = 2 (bit index 25 and 24)
private static final int TRUN_BOX_SYNC_SAMPLE_FLAGS = 0b00000010_00000000_00000000_00000000;
// unsigned int(2) sample_depends_on = 1 (bit index 25 and 24)
// bit(1) sample_is_non_sync_sample = 1 (bit index 16)
private static final int TRUN_BOX_NON_SYNC_SAMPLE_FLAGS = 0b00000001_00000001_00000000_00000000;
private Boxes() {} private Boxes() {}
public static final ImmutableList<Byte> XMP_UUID = public static final ImmutableList<Byte> XMP_UUID =
@ -598,6 +606,7 @@ import java.util.Locale;
* @return A list of all the sample durations. * @return A list of all the sample durations.
*/ */
// TODO: b/280084657 - Add support for setting last sample duration. // TODO: b/280084657 - Add support for setting last sample duration.
// TODO: b/317373578 - Consider changing return type to List<Integer>.
public static List<Long> convertPresentationTimestampsToDurationsVu( public static List<Long> convertPresentationTimestampsToDurationsVu(
List<BufferInfo> samplesInfo, List<BufferInfo> samplesInfo,
long firstSamplePresentationTimeUs, long firstSamplePresentationTimeUs,
@ -805,6 +814,82 @@ import java.util.Locale;
return BoxUtils.wrapBoxesIntoBox("ftyp", boxBytes); return BoxUtils.wrapBoxesIntoBox("ftyp", boxBytes);
} }
/** Returns the movie fragment (moof) box. */
public static ByteBuffer moof(ByteBuffer mfhdBox, List<ByteBuffer> trafBoxes) {
return BoxUtils.wrapBoxesIntoBox(
"moof", new ImmutableList.Builder<ByteBuffer>().add(mfhdBox).addAll(trafBoxes).build());
}
/** Returns the movie fragment header (mfhd) box. */
public static ByteBuffer mfhd(int sequenceNumber) {
ByteBuffer contents = ByteBuffer.allocate(2 * BYTES_PER_INTEGER);
contents.putInt(0x0); // version and flags
contents.putInt(sequenceNumber); // An unsigned int(32)
contents.flip();
return BoxUtils.wrapIntoBox("mfhd", contents);
}
/** Returns a track fragment (traf) box. */
public static ByteBuffer traf(ByteBuffer tfhdBox, ByteBuffer trunBox) {
return BoxUtils.wrapBoxesIntoBox("traf", ImmutableList.of(tfhdBox, trunBox));
}
/** Returns a track fragment header (tfhd) box. */
public static ByteBuffer tfhd(int trackId) {
ByteBuffer contents = ByteBuffer.allocate(2 * BYTES_PER_INTEGER);
contents.putInt(0x0); // version and flags
contents.putInt(trackId);
contents.flip();
return BoxUtils.wrapIntoBox("tfhd", contents);
}
/** Returns a track fragment run (trun) box. */
public static ByteBuffer trun(List<SampleMetadata> samplesMetadata) {
// 3 integers are required for each sample's metadata.
ByteBuffer contents =
ByteBuffer.allocate(2 * BYTES_PER_INTEGER + 3 * samplesMetadata.size() * BYTES_PER_INTEGER);
// 0x000100 sample-duration-present: indicates that each sample has its own duration, otherwise
// the default is used.
// 0x000200 sample-size-present: indicates that each sample has its own size, otherwise the
// default is used.
// 0x000400 sample-flags-present: indicates that each sample has its own flags, otherwise the
// default is used.
// Version is 0x0.
int versionAndFlags = 0x0 | 0x000100 | 0x000200 | 0x000400;
contents.putInt(versionAndFlags);
contents.putInt(samplesMetadata.size()); // An unsigned int(32)
for (int i = 0; i < samplesMetadata.size(); i++) {
SampleMetadata currentSampleMetadata = samplesMetadata.get(i);
contents.putInt((int) currentSampleMetadata.durationVu); // An unsigned int(32)
contents.putInt(currentSampleMetadata.size); // An unsigned int(32)
contents.putInt(
(currentSampleMetadata.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0
? TRUN_BOX_SYNC_SAMPLE_FLAGS
: TRUN_BOX_NON_SYNC_SAMPLE_FLAGS);
}
contents.flip();
return BoxUtils.wrapIntoBox("trun", contents);
}
/** Returns a movie extends (mvex) box. */
public static ByteBuffer mvex(List<ByteBuffer> trexBoxes) {
return BoxUtils.wrapBoxesIntoBox("mvex", trexBoxes);
}
/** Returns a track extends (trex) box. */
public static ByteBuffer trex(int trackId) {
ByteBuffer contents = ByteBuffer.allocate(6 * BYTES_PER_INTEGER);
contents.putInt(0x0); // version and flags
contents.putInt(trackId);
contents.putInt(1); // default_sample_description_index
contents.putInt(0); // default_sample_duration
contents.putInt(0); // default_sample_size
contents.putInt(0); // default_sample_flags
contents.flip();
return BoxUtils.wrapIntoBox("trex", contents);
}
// TODO: b/317117431 - Change this method to getLastSampleDuration(). // TODO: b/317117431 - Change this method to getLastSampleDuration().
/** Adjusts the duration of the very last sample if needed. */ /** Adjusts the duration of the very last sample if needed. */
private static void adjustLastSampleDuration( private static void adjustLastSampleDuration(

View File

@ -132,7 +132,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
ByteBuffer moovHeader; ByteBuffer moovHeader;
if (minInputPtsUs != Long.MAX_VALUE) { if (minInputPtsUs != Long.MAX_VALUE) {
moovHeader = moovGenerator.moovMetadataHeader(tracks, minInputPtsUs); moovHeader =
moovGenerator.moovMetadataHeader(tracks, minInputPtsUs, /* isFragmentedMp4= */ false);
} else { } else {
// Skip moov box, if there are no samples. // Skip moov box, if there are no samples.
moovHeader = ByteBuffer.allocate(0); moovHeader = ByteBuffer.allocate(0);

View File

@ -0,0 +1,240 @@
/*
* Copyright 2023 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 java.lang.Math.max;
import static java.lang.Math.min;
import android.media.MediaCodec;
import android.media.MediaCodec.BufferInfo;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util;
import androidx.media3.muxer.Mp4Muxer.TrackToken;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* An {@link Mp4Writer} implementation which writes samples into multiple fragments as per the
* fragmented MP4 (ISO/IEC 14496-12) standard.
*/
/* package */ final class FragmentedMp4Writer extends Mp4Writer {
/** Provides a limited set of sample metadata. */
public static class SampleMetadata {
public final long durationVu;
public final int size;
public final int flags;
public SampleMetadata(long durationsVu, int size, int flags) {
this.durationVu = durationsVu;
this.size = size;
this.flags = flags;
}
}
private final int fragmentDurationUs;
private @MonotonicNonNull Track videoTrack;
private int currentFragmentSequenceNumber;
private boolean headerCreated;
private long minInputPresentationTimeUs;
private long maxTrackDurationUs;
public FragmentedMp4Writer(
FileOutputStream outputStream,
Mp4MoovStructure moovGenerator,
AnnexBToAvccConverter annexBToAvccConverter,
int fragmentDurationUs) {
super(outputStream, moovGenerator, annexBToAvccConverter);
this.fragmentDurationUs = fragmentDurationUs;
minInputPresentationTimeUs = Long.MAX_VALUE;
currentFragmentSequenceNumber = 1;
}
@Override
public TrackToken addTrack(int sortKey, Format format) {
Track track = new Track(format);
tracks.add(track);
if (MimeTypes.isVideo(format.sampleMimeType)) {
videoTrack = track;
}
return track;
}
@Override
public void writeSampleData(
TrackToken token, ByteBuffer byteBuffer, MediaCodec.BufferInfo bufferInfo)
throws IOException {
checkArgument(token instanceof Track);
if (!headerCreated) {
createHeader();
headerCreated = true;
}
Track track = (Track) token;
if (shouldFlushPendingSamples(track, bufferInfo)) {
createFragment();
}
track.writeSampleData(byteBuffer, bufferInfo);
BufferInfo firstPendingSample = checkNotNull(track.pendingSamplesBufferInfo.peekFirst());
BufferInfo lastPendingSample = checkNotNull(track.pendingSamplesBufferInfo.peekLast());
minInputPresentationTimeUs =
min(minInputPresentationTimeUs, firstPendingSample.presentationTimeUs);
maxTrackDurationUs =
max(
maxTrackDurationUs,
lastPendingSample.presentationTimeUs - firstPendingSample.presentationTimeUs);
}
@Override
public void close() throws IOException {
try {
createFragment();
} finally {
output.close();
outputStream.close();
}
}
private void createHeader() throws IOException {
output.position(0L);
output.write(Boxes.ftyp());
// TODO: b/262704382 - Add some free space in the moov box to fit any newly added metadata and
// write moov box again in the close() method.
// The minInputPtsUs is actually ignored as there are no pending samples to write.
output.write(
moovGenerator.moovMetadataHeader(
tracks, /* minInputPtsUs= */ 0L, /* isFragmentedMp4= */ true));
}
private boolean shouldFlushPendingSamples(
Track track, MediaCodec.BufferInfo nextSampleBufferInfo) {
// If video track is present then fragment will be created based on group of pictures and
// track's duration so far.
if (videoTrack != null) {
// Video samples can be written only when complete group of pictures are present.
if (track.equals(videoTrack)
&& track.hadKeyframe
&& ((nextSampleBufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) > 0)) {
BufferInfo firstPendingSample = checkNotNull(track.pendingSamplesBufferInfo.peekFirst());
BufferInfo lastPendingSample = checkNotNull(track.pendingSamplesBufferInfo.peekLast());
return lastPendingSample.presentationTimeUs - firstPendingSample.presentationTimeUs
>= fragmentDurationUs;
}
return false;
} else {
return maxTrackDurationUs >= fragmentDurationUs;
}
}
private void createFragment() throws IOException {
// Write moof box.
List<ByteBuffer> trafBoxes = createTrafBoxes();
if (trafBoxes.isEmpty()) {
return;
}
output.write(Boxes.moof(Boxes.mfhd(currentFragmentSequenceNumber), trafBoxes));
writeMdatBox();
currentFragmentSequenceNumber++;
}
private List<ByteBuffer> createTrafBoxes() {
List<ByteBuffer> trafBoxes = new ArrayList<>();
for (int i = 0; i < tracks.size(); i++) {
Track currentTrack = tracks.get(i);
if (!currentTrack.pendingSamplesBufferInfo.isEmpty()) {
List<SampleMetadata> samplesMetadata =
processPendingSamplesBufferInfo(currentTrack, currentFragmentSequenceNumber);
ByteBuffer trun = Boxes.trun(samplesMetadata);
trafBoxes.add(Boxes.traf(Boxes.tfhd(/* trackId= */ i + 1), trun));
}
}
return trafBoxes;
}
private void writeMdatBox() throws IOException {
long mdatStartPosition = output.position();
// 4 bytes (indicating a 64-bit length field) + 4 bytes (box name) + 8 bytes (the actual length)
ByteBuffer header = ByteBuffer.allocate(16);
// This 32-bit integer in general contains the total length of the box. Here value 1 indicates
// that the actual length is stored as 64-bit integer after the box name.
header.putInt(1);
header.put(Util.getUtf8Bytes("mdat"));
header.putLong(16); // The total box length so far.
header.flip();
output.write(header);
long bytesWritten = 0;
for (int i = 0; i < tracks.size(); i++) {
Track currentTrack = tracks.get(i);
while (!currentTrack.pendingSamplesByteBuffer.isEmpty()) {
ByteBuffer currentSampleByteBuffer = currentTrack.pendingSamplesByteBuffer.removeFirst();
// Convert the H.264/H.265 samples from Annex-B format (output by MediaCodec) to
// Avcc format (required by MP4 container).
if (MimeTypes.isVideo(currentTrack.format.sampleMimeType)) {
annexBToAvccConverter.process(currentSampleByteBuffer);
}
bytesWritten += output.write(currentSampleByteBuffer);
}
}
long currentPosition = output.position();
// Skip 4 bytes (64-bit length indication) + 4 bytes (box name).
output.position(mdatStartPosition + 8);
ByteBuffer mdatSize = ByteBuffer.allocate(8); // 64-bit length.
// Additional 4 bytes (64-bit length indication) + 4 bytes (box name) + 8 bytes (actual length).
mdatSize.putLong(bytesWritten + 16);
mdatSize.flip();
output.write(mdatSize);
output.position(currentPosition);
}
private List<SampleMetadata> processPendingSamplesBufferInfo(
Track track, int fragmentSequenceNumber) {
List<BufferInfo> sampleBufferInfos = new ArrayList<>(track.pendingSamplesBufferInfo);
List<Long> sampleDurations =
Boxes.convertPresentationTimestampsToDurationsVu(
sampleBufferInfos,
/* firstSamplePresentationTimeUs= */ fragmentSequenceNumber == 1
? minInputPresentationTimeUs
: sampleBufferInfos.get(0).presentationTimeUs,
track.videoUnitTimebase(),
Mp4Muxer.LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION);
List<SampleMetadata> pendingSamplesMetadata = new ArrayList<>(sampleBufferInfos.size());
for (int i = 0; i < sampleBufferInfos.size(); i++) {
pendingSamplesMetadata.add(
new SampleMetadata(
sampleDurations.get(i),
sampleBufferInfos.get(i).size,
sampleBufferInfos.get(i).flags));
}
// Clear the queue.
track.pendingSamplesBufferInfo.clear();
return pendingSamplesMetadata;
}
}

View File

@ -59,14 +59,14 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
/** Generates a mdat header. */ /** Generates a mdat header. */
@SuppressWarnings("InlinedApi") @SuppressWarnings("InlinedApi")
public ByteBuffer moovMetadataHeader( public ByteBuffer moovMetadataHeader(
List<? extends TrackMetadataProvider> tracks, long minInputPtsUs) { List<? extends TrackMetadataProvider> tracks, long minInputPtsUs, boolean isFragmentedMp4) {
List<ByteBuffer> trakBoxes = new ArrayList<>(); List<ByteBuffer> trakBoxes = new ArrayList<>();
List<ByteBuffer> trexBoxes = new ArrayList<>();
int nextTrackId = 1; int nextTrackId = 1;
long videoDurationUs = 0L; long videoDurationUs = 0L;
for (int i = 0; i < tracks.size(); i++) { for (int i = 0; i < tracks.size(); i++) {
TrackMetadataProvider track = tracks.get(i); TrackMetadataProvider track = tracks.get(i);
if (!track.writtenSamples().isEmpty()) {
Format format = track.format(); Format format = track.format();
String languageCode = bcp47LanguageTagToIso3(format.language); String languageCode = bcp47LanguageTagToIso3(format.language);
@ -106,8 +106,7 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
mhdBox = Boxes.vmhd(); mhdBox = Boxes.vmhd();
sampleEntryBox = Boxes.videoSampleEntry(format); sampleEntryBox = Boxes.videoSampleEntry(format);
stsdBox = Boxes.stsd(sampleEntryBox); stsdBox = Boxes.stsd(sampleEntryBox);
stblBox = stblBox = Boxes.stbl(stsdBox, stts, stsz, stsc, co64, Boxes.stss(track.writtenSamples()));
Boxes.stbl(stsdBox, stts, stsz, stsc, co64, Boxes.stss(track.writtenSamples()));
break; break;
case C.TRACK_TYPE_AUDIO: case C.TRACK_TYPE_AUDIO:
handlerType = "soun"; handlerType = "soun";
@ -156,9 +155,9 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
trakBoxes.add(trakBox); trakBoxes.add(trakBox);
videoDurationUs = max(videoDurationUs, trackDurationUs); videoDurationUs = max(videoDurationUs, trackDurationUs);
trexBoxes.add(Boxes.trex(nextTrackId));
nextTrackId++; nextTrackId++;
} }
}
ByteBuffer mvhdBox = ByteBuffer mvhdBox =
Boxes.mvhd(nextTrackId, metadataCollector.modificationTimestampSeconds, videoDurationUs); Boxes.mvhd(nextTrackId, metadataCollector.modificationTimestampSeconds, videoDurationUs);
@ -173,7 +172,12 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
ByteBuffer moovBox; ByteBuffer moovBox;
moovBox = moovBox =
Boxes.moov(mvhdBox, udtaBox, metaBox, trakBoxes, /* mvexBox= */ ByteBuffer.allocate(0)); Boxes.moov(
mvhdBox,
udtaBox,
metaBox,
trakBoxes,
isFragmentedMp4 ? Boxes.mvex(trexBoxes) : ByteBuffer.allocate(0));
// Also add XMP if needed // Also add XMP if needed
if (metadataCollector.xmpData != null) { if (metadataCollector.xmpData != null) {

View File

@ -88,8 +88,18 @@ public final class Mp4Muxer {
/** A builder for {@link Mp4Muxer} instances. */ /** A builder for {@link Mp4Muxer} instances. */
public static final class Builder { public static final class Builder {
// TODO: b/262704382 - Optimize the default duration.
/**
* The default fragment duration for the {@linkplain #setFragmentedMp4Enabled(boolean)
* fragmented MP4}.
*/
public static final int DEFAULT_FRAGMENT_DURATION_US = 2_000_000;
private final FileOutputStream fileOutputStream; private final FileOutputStream fileOutputStream;
private @LastFrameDurationBehavior int lastFrameDurationBehavior; private @LastFrameDurationBehavior int lastFrameDurationBehavior;
private boolean fragmentedMp4Enabled;
private int fragmentDurationUs;
@Nullable private AnnexBToAvccConverter annexBToAvccConverter; @Nullable private AnnexBToAvccConverter annexBToAvccConverter;
/** /**
@ -100,6 +110,7 @@ public final class Mp4Muxer {
public Builder(FileOutputStream fileOutputStream) { public Builder(FileOutputStream fileOutputStream) {
this.fileOutputStream = checkNotNull(fileOutputStream); this.fileOutputStream = checkNotNull(fileOutputStream);
lastFrameDurationBehavior = LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME; lastFrameDurationBehavior = LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME;
fragmentDurationUs = DEFAULT_FRAGMENT_DURATION_US;
} }
/** /**
@ -127,18 +138,49 @@ public final class Mp4Muxer {
return this; return this;
} }
/**
* Sets whether to enable writing a fragmented MP4.
*
* <p>The default value is {@code false}.
*/
@CanIgnoreReturnValue
public Mp4Muxer.Builder setFragmentedMp4Enabled(boolean enabled) {
fragmentedMp4Enabled = enabled;
return this;
}
/**
* Sets fragment duration for the {@linkplain #setFragmentedMp4Enabled(boolean) fragmented MP4}.
*
* <p>Muxer will attempt to create fragments of the given duration but the actual duration might
* be greater depending upon the frequency of sync samples.
*
* <p>The duration is ignored for {@linkplain #setFragmentedMp4Enabled(boolean) non fragmented
* MP4}.
*
* <p>The default value is {@link #DEFAULT_FRAGMENT_DURATION_US}.
*
* @param fragmentDurationUs The fragment duration in microseconds.
* @return The {@link Mp4Muxer.Builder}.
*/
@CanIgnoreReturnValue
public Mp4Muxer.Builder setFragmentDurationUs(int fragmentDurationUs) {
this.fragmentDurationUs = fragmentDurationUs;
return this;
}
/** Builds an {@link Mp4Muxer} instance. */ /** Builds an {@link Mp4Muxer} instance. */
public Mp4Muxer build() { public Mp4Muxer build() {
MetadataCollector metadataCollector = new MetadataCollector(); MetadataCollector metadataCollector = new MetadataCollector();
Mp4MoovStructure moovStructure = Mp4MoovStructure moovStructure =
new Mp4MoovStructure(metadataCollector, lastFrameDurationBehavior); new Mp4MoovStructure(metadataCollector, lastFrameDurationBehavior);
AnnexBToAvccConverter avccConverter =
annexBToAvccConverter == null ? AnnexBToAvccConverter.DEFAULT : annexBToAvccConverter;
Mp4Writer mp4Writer = Mp4Writer mp4Writer =
new DefaultMp4Writer( fragmentedMp4Enabled
fileOutputStream, ? new FragmentedMp4Writer(
moovStructure, fileOutputStream, moovStructure, avccConverter, fragmentDurationUs)
annexBToAvccConverter == null : new DefaultMp4Writer(fileOutputStream, moovStructure, avccConverter);
? AnnexBToAvccConverter.DEFAULT
: annexBToAvccConverter);
return new Mp4Muxer(mp4Writer, metadataCollector); return new Mp4Muxer(mp4Writer, metadataCollector);
} }

View File

@ -30,6 +30,7 @@ import android.media.MediaCodec;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.ColorInfo; import androidx.media3.common.ColorInfo;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
import androidx.media3.test.utils.DumpFileAsserts; import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.DumpableMp4Box; import androidx.media3.test.utils.DumpableMp4Box;
import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.TestUtil;
@ -527,6 +528,52 @@ public class BoxesTest {
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("ftyp_box")); context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("ftyp_box"));
} }
@Test
public void createMfhdBox_matchesExpected() throws IOException {
ByteBuffer mfhdBox = Boxes.mfhd(/* sequenceNumber= */ 5);
DumpableMp4Box dumpableBox = new DumpableMp4Box(mfhdBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("mfhd_box"));
}
@Test
public void createTfhdBox_matchesExpected() throws IOException {
ByteBuffer tfhdBox = Boxes.tfhd(/* trackId= */ 1);
DumpableMp4Box dumpableBox = new DumpableMp4Box(tfhdBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("tfhd_box"));
}
@Test
public void createTrunBox_matchesExpected() throws IOException {
int sampleCount = 5;
List<SampleMetadata> samplesMetadata = new ArrayList<>(sampleCount);
for (int i = 0; i < sampleCount; i++) {
samplesMetadata.add(
new SampleMetadata(
/* durationsVu= */ 2_000L,
/* size= */ 5_000,
/* flags= */ i == 0 ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0));
}
ByteBuffer trunBox = Boxes.trun(samplesMetadata);
DumpableMp4Box dumpableBox = new DumpableMp4Box(trunBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("trun_box"));
}
@Test
public void createTrexBox_matchesExpected() throws IOException {
ByteBuffer trexBox = Boxes.trex(/* trackId= */ 2);
DumpableMp4Box dumpableBox = new DumpableMp4Box(trexBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("trex_box"));
}
private static List<MediaCodec.BufferInfo> createBufferInfoListWithSamplePresentationTimestamps( private static List<MediaCodec.BufferInfo> createBufferInfoListWithSamplePresentationTimestamps(
long... timestampsUs) { long... timestampsUs) {
List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>(); List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
ftyp (28 bytes):
Data = length 20, hash EF896440
moov (1209 bytes):
mvhd (108 bytes):
Data = length 100, hash 5CF3AC6F
trak (610 bytes):
tkhd (92 bytes):
Data = length 84, hash 112173F4
mdia (510 bytes):
mdhd (32 bytes):
Data = length 24, hash 42753A93
hdlr (44 bytes):
Data = length 36, hash A0852FF2
minf (426 bytes):
vmhd (20 bytes):
Data = length 12, hash EE830681
dinf (36 bytes):
Data = length 28, hash D535436B
stbl (362 bytes):
stsd (270 bytes):
Data = length 262, hash 58217180
stts (16 bytes):
Data = length 8, hash 94446F01
stsz (20 bytes):
Data = length 12, hash EE830681
stsc (16 bytes):
Data = length 8, hash 94446F01
co64 (16 bytes):
Data = length 8, hash 94446F01
stss (16 bytes):
Data = length 8, hash 94446F01
trak (411 bytes):
tkhd (92 bytes):
Data = length 84, hash 34D7906B
mdia (311 bytes):
mdhd (32 bytes):
Data = length 24, hash EA3D1FE6
hdlr (44 bytes):
Data = length 36, hash 49FC755F
minf (227 bytes):
smhd (16 bytes):
Data = length 8, hash 94446F01
dinf (36 bytes):
Data = length 28, hash D535436B
stbl (167 bytes):
stsd (91 bytes):
Data = length 83, hash 895E2DCB
stts (16 bytes):
Data = length 8, hash 94446F01
stsz (20 bytes):
Data = length 12, hash EE830681
stsc (16 bytes):
Data = length 8, hash 94446F01
co64 (16 bytes):
Data = length 8, hash 94446F01
mvex (72 bytes):
trex (32 bytes):
Data = length 24, hash C35D3183
trex (32 bytes):
Data = length 24, hash 14070F84
moof (2852 bytes):
mfhd (16 bytes):
Data = length 8, hash 94446F02
traf (1120 bytes):
tfhd (16 bytes):
Data = length 8, hash 94446F02
trun (1096 bytes):
Data = length 1088, hash 1F7B824F
traf (1708 bytes):
tfhd (16 bytes):
Data = length 8, hash 94446F03
trun (1684 bytes):
Data = length 1676, hash 46E974DC
mdat (5712395 bytes):
Data = length 5712379, hash 86B2819D
moof (1220 bytes):
mfhd (16 bytes):
Data = length 8, hash 94446F03
traf (484 bytes):
tfhd (16 bytes):
Data = length 8, hash 94446F02
trun (460 bytes):
Data = length 452, hash 36E6F796
traf (712 bytes):
tfhd (16 bytes):
Data = length 8, hash 94446F03
trun (688 bytes):
Data = length 680, hash 4E3D2F16
mdat (2364929 bytes):
Data = length 2364913, hash D363A845

View File

@ -0,0 +1,2 @@
mfhd (16 bytes):
Data = length 8, hash 94446F06

View File

@ -0,0 +1,2 @@
tfhd (16 bytes):
Data = length 8, hash 94446F02

View File

@ -0,0 +1,2 @@
trex (32 bytes):
Data = length 24, hash 14070F84

View File

@ -0,0 +1,2 @@
trun (76 bytes):
Data = length 68, hash 750F9113

View File

@ -25,7 +25,8 @@ import java.nio.ByteBuffer;
@UnstableApi @UnstableApi
public final class DumpableMp4Box implements Dumper.Dumpable { public final class DumpableMp4Box implements Dumper.Dumpable {
private static final ImmutableSet<String> CONTAINER_BOXES = private static final ImmutableSet<String> CONTAINER_BOXES =
ImmutableSet.of("moov", "trak", "mdia", "minf", "stbl", "edts", "meta"); ImmutableSet.of(
"moov", "trak", "mdia", "minf", "stbl", "edts", "meta", "mvex", "moof", "traf");
private final ParsableByteArray box; private final ParsableByteArray box;
/*** /***