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:
parent
b0e00a7d28
commit
e0257f403f
@ -20,9 +20,12 @@ import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||
import android.content.Context;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaExtractor;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.util.MediaFormatUtil;
|
||||
import androidx.media3.extractor.mp4.FragmentedMp4Extractor;
|
||||
import androidx.media3.extractor.mp4.Mp4Extractor;
|
||||
import androidx.media3.test.utils.DumpFileAsserts;
|
||||
import androidx.media3.test.utils.DumpableMp4Box;
|
||||
import androidx.media3.test.utils.FakeExtractorOutput;
|
||||
import androidx.media3.test.utils.TestUtil;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
@ -77,7 +80,7 @@ public class Mp4MuxerEndToEndTest {
|
||||
|
||||
@Test
|
||||
public void createMp4File_fromInputFileSampleData_matchesExpected() throws IOException {
|
||||
Mp4Muxer mp4Muxer = null;
|
||||
@Nullable Mp4Muxer mp4Muxer = null;
|
||||
|
||||
try {
|
||||
mp4Muxer = new Mp4Muxer.Builder(checkNotNull(outputStream)).build();
|
||||
@ -114,6 +117,55 @@ public class Mp4MuxerEndToEndTest {
|
||||
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 {
|
||||
MediaExtractor extractor = new MediaExtractor();
|
||||
extractor.setDataSource(
|
||||
|
@ -31,6 +31,7 @@ import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.container.NalUnitUtil;
|
||||
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.primitives.Bytes;
|
||||
@ -48,6 +49,13 @@ import java.util.Locale;
|
||||
* buffers}.
|
||||
*/
|
||||
/* 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() {}
|
||||
|
||||
public static final ImmutableList<Byte> XMP_UUID =
|
||||
@ -598,6 +606,7 @@ import java.util.Locale;
|
||||
* @return A list of all the sample durations.
|
||||
*/
|
||||
// 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(
|
||||
List<BufferInfo> samplesInfo,
|
||||
long firstSamplePresentationTimeUs,
|
||||
@ -805,6 +814,82 @@ import java.util.Locale;
|
||||
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().
|
||||
/** Adjusts the duration of the very last sample if needed. */
|
||||
private static void adjustLastSampleDuration(
|
||||
|
@ -132,7 +132,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
ByteBuffer moovHeader;
|
||||
if (minInputPtsUs != Long.MAX_VALUE) {
|
||||
moovHeader = moovGenerator.moovMetadataHeader(tracks, minInputPtsUs);
|
||||
moovHeader =
|
||||
moovGenerator.moovMetadataHeader(tracks, minInputPtsUs, /* isFragmentedMp4= */ false);
|
||||
} else {
|
||||
// Skip moov box, if there are no samples.
|
||||
moovHeader = ByteBuffer.allocate(0);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -59,14 +59,14 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
|
||||
/** Generates a mdat header. */
|
||||
@SuppressWarnings("InlinedApi")
|
||||
public ByteBuffer moovMetadataHeader(
|
||||
List<? extends TrackMetadataProvider> tracks, long minInputPtsUs) {
|
||||
List<? extends TrackMetadataProvider> tracks, long minInputPtsUs, boolean isFragmentedMp4) {
|
||||
List<ByteBuffer> trakBoxes = new ArrayList<>();
|
||||
List<ByteBuffer> trexBoxes = new ArrayList<>();
|
||||
|
||||
int nextTrackId = 1;
|
||||
long videoDurationUs = 0L;
|
||||
for (int i = 0; i < tracks.size(); i++) {
|
||||
TrackMetadataProvider track = tracks.get(i);
|
||||
if (!track.writtenSamples().isEmpty()) {
|
||||
Format format = track.format();
|
||||
String languageCode = bcp47LanguageTagToIso3(format.language);
|
||||
|
||||
@ -106,8 +106,7 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
|
||||
mhdBox = Boxes.vmhd();
|
||||
sampleEntryBox = Boxes.videoSampleEntry(format);
|
||||
stsdBox = Boxes.stsd(sampleEntryBox);
|
||||
stblBox =
|
||||
Boxes.stbl(stsdBox, stts, stsz, stsc, co64, Boxes.stss(track.writtenSamples()));
|
||||
stblBox = Boxes.stbl(stsdBox, stts, stsz, stsc, co64, Boxes.stss(track.writtenSamples()));
|
||||
break;
|
||||
case C.TRACK_TYPE_AUDIO:
|
||||
handlerType = "soun";
|
||||
@ -156,9 +155,9 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
|
||||
|
||||
trakBoxes.add(trakBox);
|
||||
videoDurationUs = max(videoDurationUs, trackDurationUs);
|
||||
trexBoxes.add(Boxes.trex(nextTrackId));
|
||||
nextTrackId++;
|
||||
}
|
||||
}
|
||||
|
||||
ByteBuffer mvhdBox =
|
||||
Boxes.mvhd(nextTrackId, metadataCollector.modificationTimestampSeconds, videoDurationUs);
|
||||
@ -173,7 +172,12 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
|
||||
|
||||
ByteBuffer 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
|
||||
if (metadataCollector.xmpData != null) {
|
||||
|
@ -88,8 +88,18 @@ public final class Mp4Muxer {
|
||||
|
||||
/** A builder for {@link Mp4Muxer} instances. */
|
||||
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 @LastFrameDurationBehavior int lastFrameDurationBehavior;
|
||||
private boolean fragmentedMp4Enabled;
|
||||
private int fragmentDurationUs;
|
||||
@Nullable private AnnexBToAvccConverter annexBToAvccConverter;
|
||||
|
||||
/**
|
||||
@ -100,6 +110,7 @@ public final class Mp4Muxer {
|
||||
public Builder(FileOutputStream fileOutputStream) {
|
||||
this.fileOutputStream = checkNotNull(fileOutputStream);
|
||||
lastFrameDurationBehavior = LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME;
|
||||
fragmentDurationUs = DEFAULT_FRAGMENT_DURATION_US;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,18 +138,49 @@ public final class Mp4Muxer {
|
||||
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. */
|
||||
public Mp4Muxer build() {
|
||||
MetadataCollector metadataCollector = new MetadataCollector();
|
||||
Mp4MoovStructure moovStructure =
|
||||
new Mp4MoovStructure(metadataCollector, lastFrameDurationBehavior);
|
||||
AnnexBToAvccConverter avccConverter =
|
||||
annexBToAvccConverter == null ? AnnexBToAvccConverter.DEFAULT : annexBToAvccConverter;
|
||||
Mp4Writer mp4Writer =
|
||||
new DefaultMp4Writer(
|
||||
fileOutputStream,
|
||||
moovStructure,
|
||||
annexBToAvccConverter == null
|
||||
? AnnexBToAvccConverter.DEFAULT
|
||||
: annexBToAvccConverter);
|
||||
fragmentedMp4Enabled
|
||||
? new FragmentedMp4Writer(
|
||||
fileOutputStream, moovStructure, avccConverter, fragmentDurationUs)
|
||||
: new DefaultMp4Writer(fileOutputStream, moovStructure, avccConverter);
|
||||
|
||||
return new Mp4Muxer(mp4Writer, metadataCollector);
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ import android.media.MediaCodec;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.ColorInfo;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.muxer.FragmentedMp4Writer.SampleMetadata;
|
||||
import androidx.media3.test.utils.DumpFileAsserts;
|
||||
import androidx.media3.test.utils.DumpableMp4Box;
|
||||
import androidx.media3.test.utils.TestUtil;
|
||||
@ -527,6 +528,52 @@ public class BoxesTest {
|
||||
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(
|
||||
long... timestampsUs) {
|
||||
List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>();
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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
|
@ -0,0 +1,2 @@
|
||||
mfhd (16 bytes):
|
||||
Data = length 8, hash 94446F06
|
@ -0,0 +1,2 @@
|
||||
tfhd (16 bytes):
|
||||
Data = length 8, hash 94446F02
|
@ -0,0 +1,2 @@
|
||||
trex (32 bytes):
|
||||
Data = length 24, hash 14070F84
|
@ -0,0 +1,2 @@
|
||||
trun (76 bytes):
|
||||
Data = length 68, hash 750F9113
|
@ -25,7 +25,8 @@ import java.nio.ByteBuffer;
|
||||
@UnstableApi
|
||||
public final class DumpableMp4Box implements Dumper.Dumpable {
|
||||
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;
|
||||
|
||||
/***
|
||||
|
Loading…
x
Reference in New Issue
Block a user