Open source muxer module

PiperOrigin-RevId: 526683141
This commit is contained in:
sheenachhabra 2023-04-24 18:22:50 +01:00 committed by Ian Baker
parent 324115f6cf
commit 67639cafd7
27 changed files with 4180 additions and 0 deletions

View File

@ -91,6 +91,9 @@
an input frame was pending processing.
* Query codecs via `MediaCodecList` instead of using
`findDecoder/EncoderForFormat` utilities, to expand support.
* Muxer:
* Add a new muxer library which can be used to create an MP4 container
file.
* DASH:
* Remove the media time offset from `MediaLoadData.startTimeMs` and
`MediaLoadData.endTimeMs` for multi period DASH streams.

View File

@ -81,6 +81,9 @@ project(modulePrefix + 'lib-cast').projectDir = new File(rootDir, 'libraries/cas
include modulePrefix + 'lib-effect'
project(modulePrefix + 'lib-effect').projectDir = new File(rootDir, 'libraries/effect')
include modulePrefix + 'lib-muxer'
project(modulePrefix + 'lib-muxer').projectDir = new File(rootDir, 'libraries/muxer')
include modulePrefix + 'lib-transformer'
project(modulePrefix + 'lib-transformer').projectDir = new File(rootDir, 'libraries/transformer')

19
libraries/muxer/README.md Normal file
View File

@ -0,0 +1,19 @@
# Muxer module
Provides functionality for producing media container files.
## Getting the module
The easiest way to get the module is to add it as a gradle dependency:
```gradle
implementation 'androidx.media3:media3-muxer:1.X.X'
```
where `1.X.X` is the version, which must match the version of the other media
modules being used.
Alternatively, you can clone this GitHub project and depend on the module
locally. Instructions for doing this can be found in the [top level README][].
[top level README]: ../../README.md

View File

@ -0,0 +1,65 @@
// Copyright 2022 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.
apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
android {
defaultConfig {
// The following argument makes the Android Test Orchestrator run its
// "pm clear" command after each test invocation. This command ensures
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
multiDexEnabled true
}
buildTypes {
debug {
testCoverageEnabled = true
}
}
sourceSets {
androidTest.assets.srcDir '../test_data/src/test/assets/'
test.assets.srcDir '../test_data/src/test/assets/'
}
}
ext {
javadocTitle = 'Muxer module'
}
dependencies {
implementation project(modulePrefix + 'lib-common')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'lib-extractor')
testImplementation project(modulePrefix + 'test-utils-robolectric')
testImplementation project(modulePrefix + 'test-utils')
testImplementation project(modulePrefix + 'test-data')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
testImplementation 'com.google.truth:truth:' + truthVersion
androidTestImplementation 'junit:junit:' + junitVersion
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
androidTestImplementation 'com.google.truth:truth:' + truthVersion
androidTestImplementation project(modulePrefix + 'test-utils')
androidTestImplementation project(modulePrefix + 'lib-extractor')
}
apply from: '../../javadoc_library.gradle'
ext {
releaseArtifactId = 'media3-muxer'
releaseName = 'Media3 Muxer module'
}
apply from: '../../publish.gradle'

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2022 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="androidx.media3.muxer">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-sdk/>
<application
android:allowBackup="false"
tools:ignore="MissingApplicationIcon,HardcodedDebugMode"
android:usesCleartextTraffic="true"/>
<instrumentation
android:targetPackage="androidx.media3.muxer"
android:name="androidx.test.runner.AndroidJUnitRunner"/>
</manifest>

View File

@ -0,0 +1,28 @@
/*
* 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;
/** Utilities for muxer test cases. */
/* package */ final class AndroidMuxerTestUtil {
private static final String DUMP_FILE_OUTPUT_DIRECTORY = "muxerdumps";
private static final String DUMP_FILE_EXTENSION = "dump";
private AndroidMuxerTestUtil() {}
public static String getExpectedDumpFilePath(String originalFileName) {
return DUMP_FILE_OUTPUT_DIRECTORY + '/' + originalFileName + '.' + DUMP_FILE_EXTENSION;
}
}

View File

@ -0,0 +1,148 @@
/*
* 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 android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import androidx.media3.common.util.MediaFormatUtil;
import androidx.media3.extractor.mp4.Mp4Extractor;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import com.google.common.collect.ImmutableList;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
/** End to end instrumentation tests for {@link Mp4Muxer}. */
@RunWith(Parameterized.class)
public class Mp4MuxerEndToEndTest {
private static final String H264_MP4 = "sample.mp4";
private static final String H265_HDR10_MP4 = "hdr10-720p.mp4";
private static final String AV1_MP4 = "sample_av1.mp4";
@Parameters(name = "{0}")
public static ImmutableList<String> mediaSamples() {
return ImmutableList.of(H264_MP4, H265_HDR10_MP4, AV1_MP4);
}
@Parameter public String inputFile;
@Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
private static final String MP4_FILE_ASSET_DIRECTORY = "media/mp4/";
private Context context;
private String outputPath;
private FileOutputStream outputStream;
@Before
public void setUp() throws Exception {
context = ApplicationProvider.getApplicationContext();
outputPath = temporaryFolder.newFile("muxeroutput.mp4").getPath();
outputStream = new FileOutputStream(outputPath);
}
@After
public void tearDown() throws IOException {
outputStream.close();
}
@Test
public void createMp4File_fromInputFileSampleData_matchesExpected() throws IOException {
Mp4Muxer mp4Muxer = null;
try {
mp4Muxer = new Mp4Muxer.Builder(outputStream).build();
feedInputDataToMuxer(mp4Muxer, inputFile);
} finally {
if (mp4Muxer != null) {
mp4Muxer.close();
}
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputPath);
DumpFileAsserts.assertOutput(
context, fakeExtractorOutput, AndroidMuxerTestUtil.getExpectedDumpFilePath(inputFile));
}
@Test
public void createMp4File_muxerNotClosed_createsPartiallyWrittenValidFile() throws IOException {
Mp4Muxer mp4Muxer = new Mp4Muxer.Builder(outputStream).build();
feedInputDataToMuxer(mp4Muxer, H265_HDR10_MP4);
// Muxer not closed.
// Audio sample written = 192 out of 195.
// Video sample written = 94 out of 127.
// Output is still a valid MP4 file.
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputPath);
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
AndroidMuxerTestUtil.getExpectedDumpFilePath("partial_" + H265_HDR10_MP4));
}
private void feedInputDataToMuxer(Mp4Muxer mp4Muxer, String inputFileName) throws IOException {
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(
context.getResources().getAssets().openFd(MP4_FILE_ASSET_DIRECTORY + inputFileName));
List<Mp4Muxer.TrackToken> addedTracks = new ArrayList<>();
int sortKey = 0;
for (int i = 0; i < extractor.getTrackCount(); i++) {
Mp4Muxer.TrackToken trackToken =
mp4Muxer.addTrack(
sortKey++, MediaFormatUtil.createFormatFromMediaFormat(extractor.getTrackFormat(i)));
addedTracks.add(trackToken);
extractor.selectTrack(i);
}
do {
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
bufferInfo.flags = extractor.getSampleFlags();
bufferInfo.offset = 0;
bufferInfo.presentationTimeUs = extractor.getSampleTime();
int sampleSize = (int) extractor.getSampleSize();
bufferInfo.size = sampleSize;
ByteBuffer sampleBuffer = ByteBuffer.allocateDirect(sampleSize);
extractor.readSampleData(sampleBuffer, /* offset= */ 0);
sampleBuffer.rewind();
mp4Muxer.writeSampleData(
addedTracks.get(extractor.getSampleTrackIndex()), sampleBuffer, bufferInfo);
} while (extractor.advance());
extractor.release();
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2022 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.
-->
<manifest package="androidx.media3.muxer">
<uses-sdk />
</manifest>

View File

@ -0,0 +1,64 @@
/*
* 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 androidx.media3.common.util.UnstableApi;
import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
/**
* Converts a buffer containing H.264/H.265 NAL units from the Annex-B format (ISO/IEC 14496-14
* Annex B, which uses start codes to delineate NAL units) to the avcC format (ISO/IEC 14496-15,
* which uses length prefixes).
*/
@UnstableApi
public interface AnnexBToAvccConverter {
/** Default implementation for {@link AnnexBToAvccConverter}. */
AnnexBToAvccConverter DEFAULT =
(ByteBuffer inputBuffer) -> {
if (!inputBuffer.hasRemaining()) {
return;
}
checkArgument(
inputBuffer.position() == 0, "The input buffer should have position set to 0.");
ImmutableList<ByteBuffer> nalUnitList = AnnexBUtils.findNalUnits(inputBuffer);
for (int i = 0; i < nalUnitList.size(); i++) {
int currentNalUnitLength = nalUnitList.get(i).remaining();
// Replace the start code with the NAL unit length.
inputBuffer.putInt(currentNalUnitLength);
// Shift the input buffer's position to next start code.
int newPosition = inputBuffer.position() + currentNalUnitLength;
inputBuffer.position(newPosition);
}
inputBuffer.rewind();
};
/**
* Processes a buffer in-place.
*
* <p>Expects a {@link ByteBuffer} input with a zero offset.
*
* @param inputBuffer The buffer to be converted.
*/
void process(ByteBuffer inputBuffer);
}

View File

@ -0,0 +1,96 @@
/*
* Copyright 2022 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 com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
/** NAL unit utilities for start codes and emulation prevention. */
/* package */ final class AnnexBUtils {
private AnnexBUtils() {}
/**
* Splits a {@link ByteBuffer} into individual NAL units (0x00000001 start code).
*
* <p>An empty list is returned if the input is not NAL units.
*
* <p>The position of the input buffer is unchanged after calling this method.
*/
public static ImmutableList<ByteBuffer> findNalUnits(ByteBuffer input) {
if (input.remaining() < 4 || input.getInt(0) != 1) {
return ImmutableList.of();
}
ImmutableList.Builder<ByteBuffer> nalUnits = new ImmutableList.Builder<>();
int lastStart = 4;
int zerosSeen = 0;
for (int i = 4; i < input.limit(); i++) {
if (input.get(i) == 1 && zerosSeen >= 3) {
// We're just looking at a start code.
nalUnits.add(getBytes(input, lastStart, i - 3 - lastStart));
lastStart = i + 1;
}
// Handle the end of the stream.
if (i == input.limit() - 1) {
nalUnits.add(getBytes(input, lastStart, input.limit() - lastStart));
}
if (input.get(i) == 0) {
zerosSeen++;
} else {
zerosSeen = 0;
}
}
input.rewind();
return nalUnits.build();
}
/** Removes Annex-B emulation prevention bytes from a buffer. */
public static ByteBuffer stripEmulationPrevention(ByteBuffer input) {
// For simplicity, we allocate the same number of bytes (although the eventual number might be
// smaller).
ByteBuffer output = ByteBuffer.allocate(input.limit());
int zerosSeen = 0;
for (int i = 0; i < input.limit(); i++) {
boolean lookingAtEmulationPreventionByte = input.get(i) == 0x03 && zerosSeen >= 2;
// Only copy bytes if they aren't emulation prevention bytes.
if (!lookingAtEmulationPreventionByte) {
output.put(input.get(i));
}
if (input.get(i) == 0) {
zerosSeen++;
} else {
zerosSeen = 0;
}
}
output.flip();
return output;
}
private static ByteBuffer getBytes(ByteBuffer buf, int offset, int length) {
ByteBuffer result = buf.duplicate();
result.position(offset);
result.limit(offset + length);
return result.slice();
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2022 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 com.google.common.base.Charsets;
import java.nio.ByteBuffer;
import java.util.List;
/** Utilities for dealing with MP4 boxes. */
/* package */ final class BoxUtils {
private static final int BOX_TYPE_BYTES = 4;
private static final int BOX_SIZE_BYTES = 4;
private BoxUtils() {}
/** Wraps content into a box, prefixing it with a length and a box type. */
public static ByteBuffer wrapIntoBox(String boxType, ByteBuffer contents) {
byte[] typeByteArray = boxType.getBytes(Charsets.UTF_8);
return wrapIntoBox(typeByteArray, contents);
}
/**
* Wraps content into a box, prefixing it with a length and a box type.
*
* <p>Use this method for box types with special characters. For example location box, which has a
* copyright symbol in the beginning.
*/
public static ByteBuffer wrapIntoBox(byte[] boxType, ByteBuffer contents) {
ByteBuffer box = ByteBuffer.allocate(contents.remaining() + BOX_TYPE_BYTES + BOX_SIZE_BYTES);
box.putInt(contents.remaining() + BOX_TYPE_BYTES + BOX_SIZE_BYTES);
box.put(boxType, 0, BOX_SIZE_BYTES);
box.put(contents);
box.flip();
return box;
}
/** Concatenate multiple boxes into a box, prefixing it with a length and a box type. */
public static ByteBuffer wrapBoxesIntoBox(String boxType, List<ByteBuffer> boxes) {
int totalSize = BOX_TYPE_BYTES + BOX_SIZE_BYTES;
for (int i = 0; i < boxes.size(); i++) {
totalSize += boxes.get(i).limit();
}
ByteBuffer result = ByteBuffer.allocate(totalSize);
result.putInt(totalSize);
result.put(boxType.getBytes(Charsets.UTF_8), 0, BOX_TYPE_BYTES);
for (int i = 0; i < boxes.size(); i++) {
result.put(boxes.get(i));
}
result.flip();
return result;
}
/**
* Concatenates multiple {@linkplain ByteBuffer byte buffers} into a single {@link ByteBuffer}.
*/
public static ByteBuffer concatenateBuffers(ByteBuffer... buffers) {
int totalSize = 0;
for (ByteBuffer buffer : buffers) {
totalSize += buffer.limit();
}
ByteBuffer result = ByteBuffer.allocate(totalSize);
for (ByteBuffer buffer : buffers) {
result.put(buffer);
}
result.flip();
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,97 @@
/*
* 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 android.media.MediaFormat;
import com.google.common.collect.ImmutableList;
/** Utilities for color information. */
/* package */ final class ColorUtils {
// The constants are defined as per ISO/IEC 29199-2 (mentioned in MP4 spec ISO/IEC 14496-12:
// 8.5.2.3).
private static final short TRANSFER_SMPTE170_M = 1; // Main; also 6, 14 and 15
private static final short TRANSFER_UNSPECIFIED = 2;
private static final short TRANSFER_GAMMA22 = 4;
private static final short TRANSFER_GAMMA28 = 5;
private static final short TRANSFER_SMPTE240_M = 7;
private static final short TRANSFER_LINEAR = 8;
private static final short TRANSFER_OTHER = 9; // Also 10
private static final short TRANSFER_XV_YCC = 11;
private static final short TRANSFER_BT1361 = 12;
private static final short TRANSFER_SRGB = 13;
private static final short TRANSFER_ST2084 = 16;
private static final short TRANSFER_ST428 = 17;
private static final short TRANSFER_HLG = 18;
// MediaFormat contains three color-related fields: "standard", "transfer" and "range". The color
// standard maps to "primaries" and "matrix" in the "colr" box, while "transfer" and "range" are
// mapped to a single value each (although for "transfer", it's still not the same enum values).
private static final short PRIMARIES_BT709_5 = 1;
private static final short PRIMARIES_UNSPECIFIED = 2;
private static final short PRIMARIES_BT601_6_625 = 5;
private static final short PRIMARIES_BT601_6_525 = 6; // It's also 7?
private static final short PRIMARIES_GENERIC_FILM = 8;
private static final short PRIMARIES_BT2020 = 9;
private static final short PRIMARIES_BT470_6_M = 4;
private static final short MATRIX_UNSPECIFIED = 2;
private static final short MATRIX_BT709_5 = 1;
private static final short MATRIX_BT601_6 = 6;
private static final short MATRIX_SMPTE240_M = 7;
private static final short MATRIX_BT2020 = 9;
private static final short MATRIX_BT2020_CONSTANT = 10;
private static final short MATRIX_BT470_6_M = 4;
/**
* Map from {@link MediaFormat} standards to MP4 primaries and matrix indices.
*
* <p>The i-th element corresponds to a {@link MediaFormat} value of i.
*/
public static final ImmutableList<ImmutableList<Short>>
MEDIAFORMAT_STANDARD_TO_PRIMARIES_AND_MATRIX =
ImmutableList.of(
ImmutableList.of(PRIMARIES_UNSPECIFIED, MATRIX_UNSPECIFIED), // Unspecified
ImmutableList.of(PRIMARIES_BT709_5, MATRIX_BT709_5), // BT709
ImmutableList.of(PRIMARIES_BT601_6_625, MATRIX_BT601_6), // BT601_625
ImmutableList.of(PRIMARIES_BT601_6_625, MATRIX_BT709_5), // BT601_625_Unadjusted
ImmutableList.of(PRIMARIES_BT601_6_525, MATRIX_BT601_6), // BT601_525
ImmutableList.of(PRIMARIES_BT601_6_525, MATRIX_SMPTE240_M), // BT601_525_Unadjusted
ImmutableList.of(PRIMARIES_BT2020, MATRIX_BT2020), // BT2020
ImmutableList.of(PRIMARIES_BT2020, MATRIX_BT2020_CONSTANT), // BT2020Constant
ImmutableList.of(PRIMARIES_BT470_6_M, MATRIX_BT470_6_M), // BT470M
ImmutableList.of(PRIMARIES_GENERIC_FILM, MATRIX_BT2020) // Film
);
/**
* Map from {@link MediaFormat} standards to MP4 transfer indices.
*
* <p>The i-th element corresponds to a {@link MediaFormat} value of i.
*/
public static final ImmutableList<Short> MEDIAFORMAT_TRANSFER_TO_MP4_TRANSFER =
ImmutableList.of(
TRANSFER_UNSPECIFIED, // Unspecified
TRANSFER_LINEAR, // Linear
TRANSFER_SRGB, // SRGB
TRANSFER_SMPTE170_M, // SMPTE_170M
TRANSFER_GAMMA22, // Gamma22
TRANSFER_GAMMA28, // Gamma28
TRANSFER_ST2084, // ST2084
TRANSFER_HLG // HLG
);
private ColorUtils() {}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2022 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.checkState;
import java.nio.ByteBuffer;
import java.util.LinkedHashMap;
import java.util.Map;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Collects and provides metadata: location, FPS, XMP data, etc. */
/* package */ final class MetadataCollector {
public int orientation;
public @MonotonicNonNull Mp4Location location;
public Map<String, Object> metadataPairs;
public long modificationDateUnixMs;
public @MonotonicNonNull ByteBuffer xmpData;
public MetadataCollector() {
orientation = 0;
metadataPairs = new LinkedHashMap<>();
modificationDateUnixMs = System.currentTimeMillis();
}
public void addXmp(ByteBuffer xmpData) {
checkState(this.xmpData == null);
this.xmpData = xmpData;
}
public void setOrientation(int orientation) {
this.orientation = orientation;
}
public void setLocation(float latitude, float longitude) {
location = new Mp4Location(latitude, longitude);
}
public void setCaptureFps(float captureFps) {
metadataPairs.put("com.android.capture.fps", captureFps);
}
public void addMetadata(String key, Object value) {
metadataPairs.put(key, value);
}
public void setModificationTime(long modificationDateUnixMs) {
this.modificationDateUnixMs = modificationDateUnixMs;
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2022 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;
/** Stores location data. */
/* package */ final class Mp4Location {
public final float latitude;
public final float longitude;
public Mp4Location(float latitude, float longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
}

View File

@ -0,0 +1,178 @@
/*
* Copyright 2022 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.muxer.Mp4Utils.MVHD_TIMEBASE;
import static java.lang.Math.max;
import android.media.MediaCodec.BufferInfo;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.checkerframework.checker.nullness.qual.PolyNull;
/** Builds the moov box structure of an MP4 file. */
/* package */ class Mp4MoovStructure {
/** Provides track's metadata like media format, written samples. */
public interface TrackMetadataProvider {
Format format();
int sortKey();
int videoUnitTimebase();
ImmutableList<BufferInfo> writtenSamples();
ImmutableList<Long> writtenChunkOffsets();
ImmutableList<Integer> writtenChunkSampleCounts();
}
private final MetadataCollector metadataCollector;
private final @Mp4Muxer.LastFrameDurationBehavior int lastFrameDurationBehavior;
public Mp4MoovStructure(
MetadataCollector metadataCollector,
@Mp4Muxer.LastFrameDurationBehavior int lastFrameDurationBehavior) {
this.metadataCollector = metadataCollector;
this.lastFrameDurationBehavior = lastFrameDurationBehavior;
}
/** Generates a mdat header. */
@SuppressWarnings("InlinedApi")
public ByteBuffer moovMetadataHeader(
List<? extends TrackMetadataProvider> tracks, long minInputPtsUs) {
List<ByteBuffer> trakBoxes = 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);
boolean isVideo = MimeTypes.isVideo(format.sampleMimeType);
boolean isAudio = MimeTypes.isAudio(format.sampleMimeType);
// Generate the sample durations to calculate the total duration for tkhd box.
List<Long> sampleDurationsVu =
Boxes.durationsVuForStts(
track.writtenSamples(),
minInputPtsUs,
track.videoUnitTimebase(),
lastFrameDurationBehavior);
long trackDurationInTrackUnitsVu = 0;
for (int j = 0; j < sampleDurationsVu.size(); j++) {
trackDurationInTrackUnitsVu += sampleDurationsVu.get(j);
}
long trackDurationUs =
Mp4Utils.usFromVu(trackDurationInTrackUnitsVu, track.videoUnitTimebase());
String handlerType = isVideo ? "vide" : (isAudio ? "soun" : "meta");
String handlerName = isVideo ? "VideoHandle" : (isAudio ? "SoundHandle" : "MetaHandle");
ByteBuffer stsd =
Boxes.stsd(
isVideo
? Boxes.videoSampleEntry(format)
: (isAudio
? Boxes.audioSampleEntry(format)
: Boxes.metadataSampleEntry(format)));
ByteBuffer stts = Boxes.stts(sampleDurationsVu);
ByteBuffer stsz = Boxes.stsz(track.writtenSamples());
ByteBuffer stsc = Boxes.stsc(track.writtenChunkSampleCounts());
ByteBuffer co64 = Boxes.co64(track.writtenChunkOffsets());
// The below statement is also a description of how a mdat box looks like, with all the
// inner boxes and what they actually store. Although they're technically instance methods,
// everything that is written to a box is visible in the argument list.
ByteBuffer trakBox =
Boxes.trak(
Boxes.tkhd(
nextTrackId,
// Using the time base of the entire file, not that of the track; otherwise,
// Quicktime will stretch the audio accordingly, see b/158120042.
(int) Mp4Utils.vuFromUs(trackDurationUs, MVHD_TIMEBASE),
metadataCollector.modificationDateUnixMs,
metadataCollector.orientation,
format),
Boxes.mdia(
Boxes.mdhd(
trackDurationInTrackUnitsVu,
track.videoUnitTimebase(),
metadataCollector.modificationDateUnixMs,
languageCode),
Boxes.hdlr(handlerType, handlerName),
Boxes.minf(
isVideo ? Boxes.vmhd() : (isAudio ? Boxes.smhd() : Boxes.nmhd()),
Boxes.dinf(Boxes.dref(Boxes.localUrl())),
isVideo
? Boxes.stbl(
stsd, stts, stsz, stsc, co64, Boxes.stss(track.writtenSamples()))
: Boxes.stbl(stsd, stts, stsz, stsc, co64))));
trakBoxes.add(trakBox);
videoDurationUs = max(videoDurationUs, trackDurationUs);
nextTrackId++;
}
}
ByteBuffer mvhdBox =
Boxes.mvhd(nextTrackId, metadataCollector.modificationDateUnixMs, videoDurationUs);
ByteBuffer udtaBox = Boxes.udta(metadataCollector.location);
ByteBuffer metaBox =
metadataCollector.metadataPairs.isEmpty()
? ByteBuffer.allocate(0)
: Boxes.meta(
Boxes.hdlr(/* handlerType= */ "mdta", /* handlerName= */ ""),
Boxes.keys(Lists.newArrayList(metadataCollector.metadataPairs.keySet())),
Boxes.ilst(Lists.newArrayList(metadataCollector.metadataPairs.values())));
ByteBuffer moovBox;
moovBox =
Boxes.moov(mvhdBox, udtaBox, metaBox, trakBoxes, /* mvexBox= */ ByteBuffer.allocate(0));
// Also add XMP if needed
if (metadataCollector.xmpData != null) {
return BoxUtils.concatenateBuffers(
moovBox, Boxes.uuid(Boxes.XMP_UUID, metadataCollector.xmpData.duplicate()));
} else {
// No need for another copy if there is no XMP to be appended.
return moovBox;
}
}
/** Returns an ISO 639-2/T (ISO3) language code for the IETF BCP 47 language tag. */
private static @PolyNull String bcp47LanguageTagToIso3(@PolyNull String languageTag) {
if (languageTag == null) {
return null;
}
Locale locale =
Util.SDK_INT >= 21 ? Locale.forLanguageTag(languageTag) : new Locale(languageTag);
return locale.getISO3Language().isEmpty() ? languageTag : locale.getISO3Language();
}
}

View File

@ -0,0 +1,244 @@
/*
* Copyright 2022 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.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.media.MediaCodec.BufferInfo;
import androidx.annotation.FloatRange;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.nio.ByteBuffer;
/**
* A muxer for creating an MP4 container file.
*
* <p>The muxer supports writing H264, H265 and AV1 video, AAC audio and metadata.
*
* <p>All the operations are performed on the caller thread.
*
* <p>To create an MP4 container file, the caller must:
*
* <ul>
* <li>Add tracks using {@link #addTrack(int, Format)} which will return a {@link TrackToken}.
* <li>Use the associated {@link TrackToken} when {@linkplain #writeSampleData(TrackToken,
* ByteBuffer, BufferInfo) writing samples} for that track.
* <li>{@link #close} the muxer when all data has been written.
* </ul>
*
* <p>Some key points:
*
* <ul>
* <li>Tracks can be added at any point, even after writing some samples to other tracks.
* <li>The caller is responsible for ensuring that samples of different track types are well
* interleaved by calling {@link #writeSampleData(TrackToken, ByteBuffer, BufferInfo)} in an
* order that interleaves samples from different tracks.
* <li>When writing a file, if an error occurs and the muxer is not closed, then the output MP4
* file may still have some partial data.
* </ul>
*/
@UnstableApi
public final class Mp4Muxer {
/** A token representing an added track. */
public interface TrackToken {}
/** Behavior for the last sample duration. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({
LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION,
LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME
})
public @interface LastFrameDurationBehavior {}
/** Insert a zero-length last sample. */
public static final int LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME = 0;
/**
* Use the difference between the last timestamp and the one before that as the duration of the
* last sample.
*/
public static final int LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION = 1;
/** A builder for {@link Mp4Muxer} instances. */
public static final class Builder {
private final FileOutputStream fileOutputStream;
private @LastFrameDurationBehavior int lastFrameDurationBehavior;
@Nullable private AnnexBToAvccConverter annexBToAvccConverter;
/**
* Creates a {@link Builder} instance with default values.
*
* @param fileOutputStream The {@link FileOutputStream} to write the media data to.
*/
public Builder(FileOutputStream fileOutputStream) {
this.fileOutputStream = checkNotNull(fileOutputStream);
lastFrameDurationBehavior = LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME;
}
/**
* Sets the {@link LastFrameDurationBehavior} for the video track.
*
* <p>The default value is {@link #LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME}.
*/
@CanIgnoreReturnValue
public Mp4Muxer.Builder setLastFrameDurationBehavior(
@LastFrameDurationBehavior int lastFrameDurationBehavior) {
this.lastFrameDurationBehavior = lastFrameDurationBehavior;
return this;
}
/**
* Sets the {@link AnnexBToAvccConverter} to be used by the muxer to convert H.264 and H.265 NAL
* units from the Annex-B format (using start codes to delineate NAL units) to the AVCC format
* (which uses length prefixes).
*
* <p>The default value is {@link AnnexBToAvccConverter#DEFAULT}.
*/
@CanIgnoreReturnValue
public Mp4Muxer.Builder setAnnexBToAvccConverter(AnnexBToAvccConverter annexBToAvccConverter) {
this.annexBToAvccConverter = annexBToAvccConverter;
return this;
}
/** Builds an {@link Mp4Muxer} instance. */
public Mp4Muxer build() {
MetadataCollector metadataCollector = new MetadataCollector();
Mp4MoovStructure moovStructure =
new Mp4MoovStructure(metadataCollector, lastFrameDurationBehavior);
Mp4Writer mp4Writer =
new Mp4Writer(
fileOutputStream,
moovStructure,
annexBToAvccConverter == null
? AnnexBToAvccConverter.DEFAULT
: annexBToAvccConverter);
return new Mp4Muxer(mp4Writer, metadataCollector);
}
}
private final Mp4Writer mp4Writer;
private final MetadataCollector metadataCollector;
private Mp4Muxer(Mp4Writer mp4Writer, MetadataCollector metadataCollector) {
this.mp4Writer = mp4Writer;
this.metadataCollector = metadataCollector;
}
/**
* Sets the orientation hint for the video playback.
*
* @param orientation The orientation, in degrees.
*/
public void setOrientation(int orientation) {
metadataCollector.setOrientation(orientation);
}
/**
* Sets the location.
*
* @param latitude The latitude, in degrees. Its value must be in the range [-90, 90].
* @param longitude The longitude, in degrees. Its value must be in the range [-180, 180].
*/
public void setLocation(
@FloatRange(from = -90.0, to = 90.0) float latitude,
@FloatRange(from = -180.0, to = 180.0) float longitude) {
metadataCollector.setLocation(latitude, longitude);
}
/**
* Sets the capture frame rate.
*
* @param captureFps The frame rate.
*/
public void setCaptureFps(float captureFps) {
metadataCollector.setCaptureFps(captureFps);
}
/**
* Sets the file modification time.
*
* @param modificationDateUnixMs The modification time, in milliseconds since epoch.
*/
public void setModificationTime(long modificationDateUnixMs) {
metadataCollector.setModificationTime(modificationDateUnixMs);
}
/**
* Adds custom metadata.
*
* @param key The metadata key in {@link String} format.
* @param value The metadata value in {@link String} or {@link Float} format.
*/
public void addMetadata(String key, Object value) {
metadataCollector.addMetadata(key, value);
}
/**
* Adds xmp data.
*
* @param xmp The xmp {@link ByteBuffer}.
*/
public void addXmp(ByteBuffer xmp) {
metadataCollector.addXmp(xmp);
}
/**
* Adds a track of the given media format.
*
* <p>Tracks can be added at any point before the muxer is closed, even after writing samples to
* other tracks.
*
* <p>The final order of tracks is determined by the provided sort key. Tracks with a lower sort
* key will always have a lower track id than tracks with a higher sort key. Ordering between
* tracks with the same sort key is not specified.
*
* @param sortKey The key used for sorting the track list.
* @param format The {@link Format} for the track.
* @return A unique {@link TrackToken}. It should be used in {@link #writeSampleData}.
*/
public TrackToken addTrack(int sortKey, Format format) {
return mp4Writer.addTrack(sortKey, format);
}
/**
* Writes encoded sample data.
*
* @param trackToken The {@link TrackToken} for which this sample is being written.
* @param byteBuffer The encoded sample.
* @param bufferInfo The {@link BufferInfo} related to this sample.
* @throws IOException If there is any error while writing data to the disk.
*/
public void writeSampleData(TrackToken trackToken, ByteBuffer byteBuffer, BufferInfo bufferInfo)
throws IOException {
mp4Writer.writeSampleData(trackToken, byteBuffer, bufferInfo);
}
/** Closes the MP4 file. */
public void close() throws IOException {
mp4Writer.close();
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2022 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;
/** Utilities for MP4 files. */
/* package */ final class Mp4Utils {
/**
* The maximum length of boxes which have fixed sizes.
*
* <p>Technically, we'd know how long they actually are; this upper bound is much simpler to
* produce though and we'll throw if we overflow anyway.
*/
public static final int MAX_FIXED_LEAF_BOX_SIZE = 200;
/**
* The per-video timebase, used for durations in MVHD and TKHD even if the per-track timebase is
* different (e.g. typically the sample rate for audio).
*/
public static final long MVHD_TIMEBASE = 10_000L;
private Mp4Utils() {}
/** Converts microseconds to video units, using the provided timebase. */
public static long vuFromUs(long timestampUs, long videoUnitTimebase) {
return timestampUs * videoUnitTimebase / 1_000_000L; // (division for us to s conversion)
}
/** Converts video units to microseconds, using the provided timebase. */
public static long usFromVu(long timestampVu, long videoUnitTimebase) {
return timestampVu * 1_000_000L / videoUnitTimebase;
}
}

View File

@ -0,0 +1,418 @@
/*
* Copyright 2022 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.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.Math.max;
import android.media.MediaCodec;
import android.media.MediaCodec.BufferInfo;
import android.util.Pair;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util;
import androidx.media3.muxer.Mp4Muxer.TrackToken;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Range;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/** Writes MP4 data to the disk. */
/* package */ final class Mp4Writer {
private static final long INTERLEAVE_DURATION_US = 1_000_000L;
private final AtomicBoolean hasWrittenSamples;
private final Mp4MoovStructure moovGenerator;
private final List<Track> tracks;
private final AnnexBToAvccConverter annexBToAvccConverter;
private final FileOutputStream outputStream;
private final FileChannel output;
private long mdatStart;
private long mdatEnd;
private long mdatDataEnd; // Always <= mdatEnd
// Typically written from the end of the mdat box to the end of the file.
private Range<Long> lastMoovWritten;
/**
* Creates an instance.
*
* @param outputStream The {@link FileOutputStream} to write the data to.
* @param moovGenerator An {@link Mp4MoovStructure} instance to generate the moov box.
* @param annexBToAvccConverter The {@link AnnexBToAvccConverter} to be used to convert H.264 and
* H.265 NAL units from the Annex-B format (using start codes to delineate NAL units) to the
* AVCC format (which uses length prefixes).
*/
public Mp4Writer(
FileOutputStream outputStream,
Mp4MoovStructure moovGenerator,
AnnexBToAvccConverter annexBToAvccConverter) {
this.moovGenerator = moovGenerator;
this.outputStream = outputStream;
this.output = outputStream.getChannel();
this.annexBToAvccConverter = annexBToAvccConverter;
hasWrittenSamples = new AtomicBoolean(false);
tracks = new ArrayList<>();
lastMoovWritten = Range.closed(0L, 0L);
}
public TrackToken addTrack(int sortKey, Format format) {
Track track = new Track(format, sortKey);
tracks.add(track);
Collections.sort(tracks, (a, b) -> Integer.compare(a.sortKey, b.sortKey));
return track;
}
public void writeSampleData(TrackToken token, ByteBuffer byteBuf, BufferInfo bufferInfo)
throws IOException {
checkState(token instanceof Track);
((Track) token).writeSampleData(byteBuf, bufferInfo);
}
public void close() throws IOException {
try {
for (int i = 0; i < tracks.size(); i++) {
flushPending(tracks.get(i));
}
// Leave the file empty if no samples are written.
if (hasWrittenSamples.get()) {
writeMoovAndTrim();
}
} finally {
output.close();
outputStream.close();
}
}
private void writeHeader() throws IOException {
output.position(0L);
output.write(Boxes.ftyp());
// Start with an empty mdat box.
mdatStart = output.position();
ByteBuffer header = ByteBuffer.allocate(4 + 4 + 8);
header.putInt(1); // 4 bytes, indicating a 64-bit length field
header.put(Util.getUtf8Bytes("mdat")); // 4 bytes
header.putLong(16); // 8 bytes (the actual length)
header.flip();
output.write(header);
// The box includes only its type and length.
mdatDataEnd = mdatStart + 16;
mdatEnd = mdatDataEnd;
}
private ByteBuffer assembleCurrentMoovData() {
long minInputPtsUs = Long.MAX_VALUE;
// Recalculate the min timestamp every time, in case some new samples have smaller timestamps.
for (int i = 0; i < tracks.size(); i++) {
Track track = tracks.get(i);
if (!track.writtenSamples.isEmpty()) {
minInputPtsUs = Math.min(track.writtenSamples.get(0).presentationTimeUs, minInputPtsUs);
}
}
ByteBuffer moovHeader;
if (minInputPtsUs != Long.MAX_VALUE) {
moovHeader = moovGenerator.moovMetadataHeader(tracks, minInputPtsUs);
} else {
// Skip moov box, if there are no samples.
moovHeader = ByteBuffer.allocate(0);
}
return moovHeader;
}
/**
* Replaces old moov box with the new one.
*
* <p>It doesn't really replace the existing moov box, rather it adds a new moov box at the end of
* the file. Even if this operation fails, the output MP4 file still has a valid moov box.
*
* <p>After this operation, the mdat box might have some extra space containing garbage value of
* the old moov box. This extra space gets trimmed before closing the file (in {@link
* #writeMoovAndTrim()}).
*
* @param newMoovBoxPosition The new position for the moov box.
* @param newMoovBoxData The new moov box data.
* @throws IOException If there is any error while writing data to the disk.
*/
private void safelyReplaceMoov(long newMoovBoxPosition, ByteBuffer newMoovBoxData)
throws IOException {
checkState(newMoovBoxPosition >= lastMoovWritten.upperEndpoint());
checkState(newMoovBoxPosition >= mdatEnd);
// Write a free box to the end of the file, with the new moov box wrapped into it.
output.position(newMoovBoxPosition);
output.write(BoxUtils.wrapIntoBox("free", newMoovBoxData.duplicate()));
// The current state is:
// | ftyp | mdat .. .. .. | previous moov | free (new moov)|
// Increase the length of the mdat box so that it now extends to
// the previous moov box and the header of the free box.
mdatEnd = newMoovBoxPosition + 8;
updateMdatSize();
lastMoovWritten =
Range.closed(newMoovBoxPosition, newMoovBoxPosition + newMoovBoxData.remaining());
}
/**
* Writes the final moov box and trims extra space from the mdat box.
*
* <p>This is done right before closing the file.
*
* @throws IOException If there is any error while writing data to the disk.
*/
private void writeMoovAndTrim() throws IOException {
// The current state is:
// | ftyp | mdat .. .. .. (00 00 00) | moov |
// To keep the trimming safe, first write the final moov box into the gap at the end of the mdat
// box, and only then trim the extra space.
ByteBuffer currentMoovData = assembleCurrentMoovData();
int moovBytesNeeded = currentMoovData.remaining();
// Write a temporary free box wrapping the new moov box.
int moovAndFreeBytesNeeded = moovBytesNeeded + 8;
if (mdatEnd - mdatDataEnd < moovAndFreeBytesNeeded) {
// If the gap is not big enough for the moov box, then extend the mdat box once again. This
// involves writing moov box farther away one more time.
safelyReplaceMoov(lastMoovWritten.upperEndpoint() + moovAndFreeBytesNeeded, currentMoovData);
checkState(mdatEnd - mdatDataEnd >= moovAndFreeBytesNeeded);
}
// Write out the new moov box into the gap.
long newMoovLocation = mdatDataEnd;
output.position(mdatDataEnd);
output.write(currentMoovData);
// Add a free box to account for the actual remaining length of the file.
long remainingLength = lastMoovWritten.upperEndpoint() - (newMoovLocation + moovBytesNeeded);
// Moov boxes shouldn't be too long; they can fit into a free box with a 32-bit length field.
checkState(remainingLength < Integer.MAX_VALUE);
ByteBuffer freeHeader = ByteBuffer.allocate(4 + 4);
freeHeader.putInt((int) remainingLength);
freeHeader.put((byte) 'f');
freeHeader.put((byte) 'r');
freeHeader.put((byte) 'e');
freeHeader.put((byte) 'e');
freeHeader.flip();
output.write(freeHeader);
// The moov box is actually written inside mdat box so the current state is:
// | ftyp | mdat .. .. .. (new moov) (free header ) (00 00 00) | old moov |
// Now change this to:
// | ftyp | mdat .. .. .. | new moov | free (00 00 00) (old moov) |
mdatEnd = newMoovLocation;
updateMdatSize();
lastMoovWritten = Range.closed(newMoovLocation, newMoovLocation + currentMoovData.limit());
// Remove the free box.
output.truncate(newMoovLocation + moovBytesNeeded);
}
/**
* Rewrites the moov box after accommodating extra bytes needed for the mdat box.
*
* @param bytesNeeded The extra bytes needed for the mdat box.
* @throws IOException If there is any error while writing data to the disk.
*/
private void rewriteMoovWithMdatEmptySpace(long bytesNeeded) throws IOException {
long newMoovStart = Math.max(mdatEnd + bytesNeeded, lastMoovWritten.upperEndpoint());
ByteBuffer currentMoovData = assembleCurrentMoovData();
safelyReplaceMoov(newMoovStart, currentMoovData);
}
/** Writes out any pending samples to the file. */
private void flushPending(Track track) throws IOException {
if (track.pendingSamples.isEmpty()) {
return;
}
if (!hasWrittenSamples.getAndSet(true)) {
writeHeader();
}
// Calculate the additional space required.
long bytesNeededInMdat = 0L;
for (Pair<BufferInfo, ByteBuffer> sample : track.pendingSamples) {
bytesNeededInMdat += sample.second.limit();
}
// Drop all zero-length samples.
checkState(bytesNeededInMdat > 0);
// If the required number of bytes doesn't fit in the gap between the actual data and the moov
// box, extend the file and write out the moov box to the end again.
if (mdatDataEnd + bytesNeededInMdat >= mdatEnd) {
// Reserve some extra space than required, so that mdat box extension is less frequent.
rewriteMoovWithMdatEmptySpace(
/* bytesNeeded= */ getMdatExtensionAmount(mdatDataEnd) + bytesNeededInMdat);
}
track.writtenChunkOffsets.add(mdatDataEnd);
track.writtenChunkSampleCounts.add(track.pendingSamples.size());
do {
Pair<BufferInfo, ByteBuffer> pendingPacket = track.pendingSamples.removeFirst();
BufferInfo info = pendingPacket.first;
ByteBuffer buffer = pendingPacket.second;
track.writtenSamples.add(info);
// Convert the H.264/H.265 samples from Annex-B format (output by MediaCodec) to
// Avcc format (required by MP4 container).
if (MimeTypes.isVideo(track.format.sampleMimeType)) {
annexBToAvccConverter.process(buffer);
}
buffer.rewind();
mdatDataEnd += output.write(buffer, mdatDataEnd);
} while (!track.pendingSamples.isEmpty());
checkState(mdatDataEnd <= mdatEnd);
}
private void updateMdatSize() throws IOException {
// Assuming that the mdat box has a 64-bit length, skip the box type (4 bytes) and
// the 32-bit box length field (4 bytes).
output.position(mdatStart + 8);
ByteBuffer mdatSize = ByteBuffer.allocate(8); // one long
mdatSize.putLong(mdatEnd - mdatStart);
mdatSize.flip();
output.write(mdatSize);
}
private void doInterleave() throws IOException {
for (int i = 0; i < tracks.size(); i++) {
Track track = tracks.get(i);
// TODO(b/270583563): check if we need to consider the global timestamp instead.
if (track.pendingSamples.size() > 2) {
BufferInfo firstSampleInfo = checkNotNull(track.pendingSamples.peekFirst()).first;
BufferInfo lastSampleInfo = checkNotNull(track.pendingSamples.peekLast()).first;
if (lastSampleInfo.presentationTimeUs - firstSampleInfo.presentationTimeUs
> INTERLEAVE_DURATION_US) {
flushPending(track);
}
}
}
}
/**
* Returns the number of bytes by which to extend the mdat box.
*
* @param currentFileLength The length of current file in bytes (except moov box).
* @return The mdat box extension amount in bytes.
*/
private long getMdatExtensionAmount(long currentFileLength) {
long minBytesToExtend = 500_000L;
float extensionRatio = 0.2f;
return max(minBytesToExtend, (long) (extensionRatio * currentFileLength));
}
private class Track implements TrackToken, Mp4MoovStructure.TrackMetadataProvider {
private final Format format;
private final int sortKey;
private final List<BufferInfo> writtenSamples;
private final List<Long> writtenChunkOffsets;
private final List<Integer> writtenChunkSampleCounts;
private final Deque<Pair<BufferInfo, ByteBuffer>> pendingSamples;
private boolean hadKeyframe = false;
private Track(Format format, int sortKey) {
this.format = format;
this.sortKey = sortKey;
writtenSamples = new ArrayList<>();
writtenChunkOffsets = new ArrayList<>();
writtenChunkSampleCounts = new ArrayList<>();
pendingSamples = new ArrayDeque<>();
}
public void writeSampleData(ByteBuffer byteBuf, BufferInfo bufferInfo) throws IOException {
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) > 0) {
hadKeyframe = true;
}
if (!hadKeyframe && MimeTypes.isVideo(format.sampleMimeType)) {
return;
}
if (bufferInfo.size == 0) {
return;
}
pendingSamples.addLast(Pair.create(bufferInfo, byteBuf));
doInterleave();
}
@Override
public int videoUnitTimebase() {
return MimeTypes.isAudio(format.sampleMimeType)
? 48_000 // TODO(b/270583563): Update these with actual values from mediaFormat.
: 90_000;
}
@Override
public int sortKey() {
return sortKey;
}
@Override
public ImmutableList<BufferInfo> writtenSamples() {
return ImmutableList.copyOf(writtenSamples);
}
@Override
public ImmutableList<Long> writtenChunkOffsets() {
return ImmutableList.copyOf(writtenChunkOffsets);
}
@Override
public ImmutableList<Integer> writtenChunkSampleCounts() {
return ImmutableList.copyOf(writtenChunkSampleCounts);
}
@Override
public Format format() {
return format;
}
}
}

View File

@ -0,0 +1,19 @@
/*
* Copyright 2022 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.
*/
@NonNullApi
package androidx.media3.muxer;
import androidx.media3.common.util.NonNullApi;

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2022 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.
-->
<manifest package="androidx.media3.muxer.test">
<uses-sdk/>
</manifest>

View File

@ -0,0 +1,138 @@
/*
* 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.Util.getBytesFromHexString;
import static com.google.common.truth.Truth.assertThat;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit tests for {@link AnnexBUtils}. */
@RunWith(AndroidJUnit4.class)
public class AnnexBUtilsTest {
@Test
public void findNalUnits_emptyBuffer_returnsEmptyList() {
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString(""));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).isEmpty();
}
@Test
public void findNalUnits_noNalUnit_returnsEmptyList() {
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("ABCDEFABC"));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).isEmpty();
}
@Test
public void findNalUnits_singleNalUnit_returnsSingleElement() {
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF"));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).hasSize(1);
assertThat(components.get(0)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEF")));
}
@Test
public void findNalUnits_multipleNalUnits_allReturned() {
ByteBuffer buf =
ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF00000001DDCC00000001BBAA"));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).hasSize(3);
assertThat(components.get(0)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEF")));
assertThat(components.get(1)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("DDCC")));
assertThat(components.get(2)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("BBAA")));
}
@Test
public void findNalUnits_partialStartCodes_ignored() {
// The NAL unit has lots of zeros but no start code.
ByteBuffer buf =
ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF0000AB0000CDEF00000000AB"));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).hasSize(1);
assertThat(components.get(0))
.isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEF0000AB0000CDEF00000000AB")));
}
@Test
public void findNalUnits_startCodeWithManyZeros_stillSplits() {
// The NAL unit has a start code that starts with more than 3 zeros (although too many zeros
// aren't allowed).
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF000000000001AB"));
ImmutableList<ByteBuffer> components = AnnexBUtils.findNalUnits(buf);
assertThat(components).hasSize(2);
assertThat(components.get(0)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEF0000")));
assertThat(components.get(1)).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("AB")));
}
@Test
public void stripEmulationPrevention_noEmulationPreventionBytes_copiesInput() {
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF000000000001AB"));
ByteBuffer output = AnnexBUtils.stripEmulationPrevention(buf);
assertThat(output)
.isEqualTo(ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF000000000001AB")));
}
@Test
public void stripEmulationPrevention_emulationPreventionPresent_bytesStripped() {
// The NAL unit has a 00 00 03 * sequence.
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF00000300000001AB"));
ByteBuffer output = AnnexBUtils.stripEmulationPrevention(buf);
assertThat(output)
.isEqualTo(ByteBuffer.wrap(getBytesFromHexString("00000001ABCDEF000000000001AB")));
}
@Test
public void stripEmulationPrevention_03WithoutEnoughZeros_notStripped() {
// The NAL unit has a 03 byte around, but not preceded by enough zeros.
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("ABCDEFABCD0003EFABCD03ABCD"));
ByteBuffer output = AnnexBUtils.stripEmulationPrevention(buf);
assertThat(output)
.isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEFABCD0003EFABCD03ABCD")));
}
@Test
public void stripEmulationPrevention_03AtEnd_stripped() {
// The NAL unit has a 03 byte at the very end of the input.
ByteBuffer buf = ByteBuffer.wrap(getBytesFromHexString("ABCDEF000003"));
ByteBuffer output = AnnexBUtils.stripEmulationPrevention(buf);
assertThat(output).isEqualTo(ByteBuffer.wrap(getBytesFromHexString("ABCDEF0000")));
}
}

View File

@ -0,0 +1,601 @@
/*
* 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.muxer.Mp4Muxer.LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION;
import static androidx.media3.muxer.Mp4Muxer.LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME;
import static androidx.media3.muxer.MuxerTestUtil.getExpectedDumpFilePath;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.content.Context;
import android.media.MediaCodec;
import androidx.media3.common.C;
import androidx.media3.common.ColorInfo;
import androidx.media3.common.Format;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.DumpableMp4Box;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import com.google.common.io.BaseEncoding;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit tests for {@link Boxes}. */
@RunWith(AndroidJUnit4.class)
public class BoxesTest {
// A typical timescale is ~90_000. We're using 100_000 here to simplify calculations.
// This makes one time unit equal to 10 microseconds.
private static final int VU_TIMEBASE = 100_000;
private Context context;
@Before
public void setUp() {
context = ApplicationProvider.getApplicationContext();
}
@Test
public void createTkhdBox_forVideoTrack_matchesExpected() throws IOException {
Format videoFormat = MuxerTestUtil.getFakeVideoFormat();
ByteBuffer tkhdBox =
Boxes.tkhd(
/* trackId= */ 1,
/* trackDurationVu= */ 5_000_000,
/* modificationDateUnixMs= */ 1_000_000_000,
/* orientation= */ 90,
videoFormat);
DumpableMp4Box dumpableBox = new DumpableMp4Box(tkhdBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, getExpectedDumpFilePath("video_track_tkhd_box"));
}
@Test
public void createTkhdBox_forAudioTrack_matchesExpected() throws IOException {
Format audioFormat = MuxerTestUtil.getFakeAudioFormat();
ByteBuffer tkhdBox =
Boxes.tkhd(
/* trackId= */ 1,
/* trackDurationVu= */ 5_000_000,
/* modificationDateUnixMs= */ 1_000_000_000,
/* orientation= */ 90,
audioFormat);
DumpableMp4Box dumpableBox = new DumpableMp4Box(tkhdBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, getExpectedDumpFilePath("audio_track_tkhd_box"));
}
@Test
public void createMvhdBox_matchesExpected() throws IOException {
ByteBuffer mvhdBox =
Boxes.mvhd(
/* nextEmptyTrackId= */ 3,
/* modificationDateUnixMs= */ 1_000_000_000,
/* videoDurationUs= */ 5_000_000);
DumpableMp4Box dumpableBox = new DumpableMp4Box(mvhdBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("mvhd_box"));
}
@Test
public void createMdhdBox_matchesExpected() throws IOException {
ByteBuffer mdhdBox =
Boxes.mdhd(
/* trackDurationVu= */ 5_000_000,
VU_TIMEBASE,
/* modificationDateUnixMs= */ 1_000_000_000,
/* languageCode= */ "und");
DumpableMp4Box dumpableBox = new DumpableMp4Box(mdhdBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("mdhd_box"));
}
@Test
public void createVmhdBox_matchesExpected() throws IOException {
ByteBuffer vmhdBox = Boxes.vmhd();
DumpableMp4Box dumpableBox = new DumpableMp4Box(vmhdBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("vmhd_box"));
}
@Test
public void createSmhdBox_matchesExpected() throws IOException {
ByteBuffer smhdBox = Boxes.smhd();
DumpableMp4Box dumpableBox = new DumpableMp4Box(smhdBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("smhd_box"));
}
@Test
public void createNmhdBox_matchesExpected() throws IOException {
ByteBuffer nmhdBox = Boxes.nmhd();
DumpableMp4Box dumpableBox = new DumpableMp4Box(nmhdBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("nmhd_box"));
}
@Test
public void createEmptyDinfBox_matchesExpected() throws IOException {
ByteBuffer dinfBox = Boxes.dinf(Boxes.dref(Boxes.localUrl()));
DumpableMp4Box dumpableBox = new DumpableMp4Box(dinfBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("dinf_box_empty"));
}
@Test
public void createHdlrBox_matchesExpected() throws IOException {
// Create hdlr box for video track.
ByteBuffer hdlrBox = Boxes.hdlr(/* handlerType= */ "vide", /* handlerName= */ "VideoHandle");
DumpableMp4Box dumpableBox = new DumpableMp4Box(hdlrBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("hdlr_box"));
}
@Test
public void createUdtaBox_matchesExpected() throws IOException {
Mp4Location mp4Location = new Mp4Location(33.0f, -120f);
ByteBuffer udtaBox = Boxes.udta(mp4Location);
DumpableMp4Box dumpableBox = new DumpableMp4Box(udtaBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("udta_box"));
}
@Test
public void createKeysBox_matchesExpected() throws Exception {
List<String> keyNames = ImmutableList.of("com.android.version", "com.android.capture.fps");
ByteBuffer keysBox = Boxes.keys(keyNames);
DumpableMp4Box dumpableBox = new DumpableMp4Box(keysBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("keys_box"));
}
@Test
public void createIlstBox_matchesExpected() throws Exception {
List<Object> values = ImmutableList.of("11", 120.0f);
ByteBuffer ilstBox = Boxes.ilst(values);
DumpableMp4Box dumpableBox = new DumpableMp4Box(ilstBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("ilst_box"));
}
@Test
public void createUuidBox_forXmpData_matchesExpected() throws Exception {
ByteBuffer xmpData =
ByteBuffer.wrap(TestUtil.getByteArray(context, "media/xmp/sample_datetime_xmp.xmp"));
ByteBuffer xmpUuidBox = Boxes.uuid(Boxes.XMP_UUID, xmpData);
DumpableMp4Box dumpableBox = new DumpableMp4Box(xmpUuidBox);
DumpFileAsserts.assertOutput(context, dumpableBox, getExpectedDumpFilePath("uuid_box_XMP"));
}
@Test
public void createuuidBox_withEmptyXmpData_throws() {
ByteBuffer xmpData = ByteBuffer.allocate(0);
assertThrows(IllegalArgumentException.class, () -> Boxes.uuid(Boxes.XMP_UUID, xmpData));
}
@Test
public void createAudioSampleEntryBox_forMp4a_matchesExpected() throws Exception {
Format format =
new Format.Builder()
.setPeakBitrate(128000)
.setSampleRate(48000)
.setId(3)
.setSampleMimeType("audio/mp4a-latm")
.setChannelCount(2)
.setAverageBitrate(128000)
.setLanguage("```")
.setMaxInputSize(502)
.setInitializationData(ImmutableList.of(BaseEncoding.base16().decode("1190")))
.build();
ByteBuffer audioSampleEntryBox = Boxes.audioSampleEntry(format);
DumpableMp4Box dumpableBox = new DumpableMp4Box(audioSampleEntryBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, getExpectedDumpFilePath("audio_sample_entry_box_mp4a"));
}
@Test
public void createAudioSampleEntryBox_withUnknownAudioFormat_throws() {
// The audio format contains an unknown MIME type.
Format format =
new Format.Builder()
.setPeakBitrate(128000)
.setSampleRate(48000)
.setId(3)
.setSampleMimeType("audio/mp4a-unknown")
.setChannelCount(2)
.setAverageBitrate(128000)
.setLanguage("```")
.setMaxInputSize(502)
.setInitializationData(ImmutableList.of(BaseEncoding.base16().decode("1190")))
.build();
assertThrows(IllegalArgumentException.class, () -> Boxes.audioSampleEntry(format));
}
@Test
public void createVideoSampleEntryBox_forH265_matchesExpected() throws Exception {
Format format =
new Format.Builder()
.setId(1)
.setSampleMimeType("video/hevc")
.setWidth(48)
.setLanguage("und")
.setMaxInputSize(114)
.setFrameRate(25)
.setHeight(32)
.setInitializationData(
ImmutableList.of(
BaseEncoding.base16()
.decode(
"0000000140010C01FFFF0408000003009FC800000300001E959809000000014201010408000003009FC800000300001EC1882165959AE4CAE68080000003008000000C84000000014401C173D089")))
.build();
ByteBuffer videoSampleEntryBox = Boxes.videoSampleEntry(format);
DumpableMp4Box dumpableBox = new DumpableMp4Box(videoSampleEntryBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("video_sample_entry_box_h265"));
}
@Test
public void createVideoSampleEntryBox_forH265_hdr10_matchesExpected() throws Exception {
Format format =
new Format.Builder()
.setPeakBitrate(9200)
.setId(1)
.setSampleMimeType("video/hevc")
.setAverageBitrate(9200)
.setLanguage("und")
.setWidth(256)
.setMaxInputSize(66)
.setFrameRate(25)
.setHeight(256)
.setColorInfo(
new ColorInfo.Builder()
.setColorSpace(C.COLOR_SPACE_BT2020)
.setColorTransfer(C.COLOR_TRANSFER_ST2084)
.setColorRange(C.COLOR_RANGE_LIMITED)
.build())
.setInitializationData(
ImmutableList.of(
BaseEncoding.base16()
.decode(
"0000000140010C01FFFF02200000030090000003000003003C9598090000000142010102200000030090000003000003003CA008080404D96566924CAE69C20000030002000003003210000000014401C172B46240")))
.build();
ByteBuffer videoSampleEntryBox = Boxes.videoSampleEntry(format);
DumpableMp4Box dumpableBox = new DumpableMp4Box(videoSampleEntryBox);
DumpFileAsserts.assertOutput(
context,
dumpableBox,
MuxerTestUtil.getExpectedDumpFilePath("video_sample_entry_box_h265_hdr10"));
}
@Test
public void createVideoSampleEntryBox_forH264_matchesExpected() throws Exception {
Format format =
new Format.Builder()
.setId(1)
.setSampleMimeType("video/avc")
.setLanguage("und")
.setWidth(10)
.setMaxInputSize(39)
.setFrameRate(25)
.setHeight(12)
.setInitializationData(
ImmutableList.of(
BaseEncoding.base16()
.decode("0000000167F4000A919B2BF3CB3640000003004000000C83C4896580"),
BaseEncoding.base16().decode("0000000168EBE3C448")))
.build();
ByteBuffer videoSampleEntryBox = Boxes.videoSampleEntry(format);
DumpableMp4Box dumpableBox = new DumpableMp4Box(videoSampleEntryBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("video_sample_entry_box_h264"));
}
@Test
public void createVideoSampleEntryBox_forAv1_matchesExpected() throws IOException {
Format format =
new Format.Builder()
.setId(1)
.setSampleMimeType("video/av01")
.setLanguage("und")
.setWidth(10)
.setMaxInputSize(49)
.setFrameRate(25)
.setHeight(12)
.setInitializationData(
ImmutableList.of(BaseEncoding.base16().decode("812000000A09200000019CDBFFF304")))
.build();
ByteBuffer videoSampleEntryBox = Boxes.videoSampleEntry(format);
DumpableMp4Box dumpableBox = new DumpableMp4Box(videoSampleEntryBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("video_sample_entry_box_av1"));
}
@Test
public void createVideoSampleEntryBox_withUnknownVideoFormat_throws() {
// The video format contains an unknown MIME type.
Format format =
new Format.Builder()
.setId(1)
.setSampleMimeType("video/someweirdvideoformat")
.setWidth(48)
.setLanguage("und")
.setMaxInputSize(114)
.setFrameRate(25)
.setHeight(32)
.setInitializationData(
ImmutableList.of(
BaseEncoding.base16()
.decode(
"0000000140010C01FFFF0408000003009FC800000300001E959809000000014201010408000003009FC800000300001EC1882165959AE4CAE68080000003008000000C84000000014401C173D089")))
.build();
assertThrows(IllegalArgumentException.class, () -> Boxes.videoSampleEntry(format));
}
@Test
public void
getDurationsVuForStts_singleSampleAtZeroTimestamp_lastFrameDurationShort_returnsSingleZeroLengthSample() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(0L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME);
assertThat(durationsVu).containsExactly(0L);
}
@Test
public void
getDurationsVuForStts_singleSampleAtZeroTimestamp_lastFrameDurationDuplicate_returnsSingleZeroLengthSample() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(0L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION);
assertThat(durationsVu).containsExactly(0L);
}
@Test
public void
getDurationsVuForStts_singleSampleAtNonZeroTimestamp_lastFrameDurationShort_returnsSampleLengthEqualsTimestamp() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(5_000L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME);
assertThat(durationsVu).containsExactly(500L);
}
@Test
public void
getDurationsVuForStts_singleSampleAtNonZeroTimestamp_lastFrameDurationDuplicate_returnsSampleLengthEqualsTimestamp() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(5_000L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION);
assertThat(durationsVu).containsExactly(500L);
}
@Test
public void
getDurationsVuForStts_differentSampleDurations_lastFrameDurationShort_returnsLastSampleOfZeroDuration() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(0L, 30_000L, 80_000L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME);
assertThat(durationsVu).containsExactly(3_000L, 5_000L, 0L);
}
@Test
public void
getDurationsVuForStts_differentSampleDurations_lastFrameDurationDuplicate_returnsLastSampleOfDuplicateDuration() {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSamplePresentationTimestamps(0L, 30_000L, 80_000L);
List<Long> durationsVu =
Boxes.durationsVuForStts(
sampleBufferInfos,
/* minInputPresentationTimestampUs= */ 0L,
VU_TIMEBASE,
LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION);
assertThat(durationsVu).containsExactly(3_000L, 5_000L, 5_000L);
}
@Test
public void createSttsBox_withSingleSampleDuration_matchesExpected() throws IOException {
ImmutableList<Long> sampleDurations = ImmutableList.of(500L);
ByteBuffer sttsBox = Boxes.stts(sampleDurations);
DumpableMp4Box dumpableBox = new DumpableMp4Box(sttsBox);
DumpFileAsserts.assertOutput(
context,
dumpableBox,
MuxerTestUtil.getExpectedDumpFilePath("stts_box_single_sample_duration"));
}
@Test
public void createSttsBox_withAllDifferentSampleDurations_matchesExpected() throws IOException {
ImmutableList<Long> sampleDurations = ImmutableList.of(1_000L, 2_000L, 3_000L, 5_000L);
ByteBuffer sttsBox = Boxes.stts(sampleDurations);
DumpableMp4Box dumpableBox = new DumpableMp4Box(sttsBox);
DumpFileAsserts.assertOutput(
context,
dumpableBox,
MuxerTestUtil.getExpectedDumpFilePath("stts_box_all_different_sample_durations"));
}
@Test
public void createSttsBox_withFewConsecutiveSameSampleDurations_matchesExpected()
throws IOException {
ImmutableList<Long> sampleDurations = ImmutableList.of(1_000L, 2_000L, 2_000L, 2_000L);
ByteBuffer sttsBox = Boxes.stts(sampleDurations);
DumpableMp4Box dumpableBox = new DumpableMp4Box(sttsBox);
DumpFileAsserts.assertOutput(
context,
dumpableBox,
MuxerTestUtil.getExpectedDumpFilePath("stts_box_few_same_sample_durations"));
}
@Test
public void createStszBox_matchesExpected() throws IOException {
List<MediaCodec.BufferInfo> sampleBufferInfos =
createBufferInfoListWithSampleSizes(100, 200, 150, 200);
ByteBuffer stszBox = Boxes.stsz(sampleBufferInfos);
DumpableMp4Box dumpableBox = new DumpableMp4Box(stszBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("stsz_box"));
}
@Test
public void createStscBox_matchesExpected() throws IOException {
ImmutableList<Integer> chunkSampleCounts = ImmutableList.of(100, 500, 200, 100);
ByteBuffer stscBox = Boxes.stsc(chunkSampleCounts);
DumpableMp4Box dumpableBox = new DumpableMp4Box(stscBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("stsc_box"));
}
@Test
public void createCo64Box_matchesExpected() throws IOException {
ImmutableList<Long> chunkOffsets = ImmutableList.of(1_000L, 5_000L, 7_000L, 10_000L);
ByteBuffer co64Box = Boxes.co64(chunkOffsets);
DumpableMp4Box dumpableBox = new DumpableMp4Box(co64Box);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("co64_box"));
}
@Test
public void createStssBox_matchesExpected() throws IOException {
List<MediaCodec.BufferInfo> sampleBufferInfos = createBufferInfoListWithSomeKeyFrames();
ByteBuffer stssBox = Boxes.stss(sampleBufferInfos);
DumpableMp4Box dumpableBox = new DumpableMp4Box(stssBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("stss_box"));
}
@Test
public void createFtypBox_matchesExpected() throws IOException {
ByteBuffer ftypBox = Boxes.ftyp();
DumpableMp4Box dumpableBox = new DumpableMp4Box(ftypBox);
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("ftyp_box"));
}
private static List<MediaCodec.BufferInfo> createBufferInfoListWithSamplePresentationTimestamps(
long... timestampsUs) {
List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>();
for (long timestampUs : timestampsUs) {
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
bufferInfo.presentationTimeUs = timestampUs;
bufferInfoList.add(bufferInfo);
}
return bufferInfoList;
}
private static List<MediaCodec.BufferInfo> createBufferInfoListWithSampleSizes(int... sizes) {
List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>();
for (int size : sizes) {
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
bufferInfo.size = size;
bufferInfoList.add(bufferInfo);
}
return bufferInfoList;
}
private static List<MediaCodec.BufferInfo> createBufferInfoListWithSomeKeyFrames() {
List<MediaCodec.BufferInfo> bufferInfoList = new ArrayList<>();
for (int i = 0; i < 30; i++) {
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
if (i % 5 == 0) { // Make every 5th frame as key frame.
bufferInfo.flags = MediaCodec.BUFFER_FLAG_KEY_FRAME;
}
bufferInfoList.add(bufferInfo);
}
return bufferInfoList;
}
}

View File

@ -0,0 +1,92 @@
/*
* 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 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;
/** Unit tests for {@link AnnexBToAvccConverter#DEFAULT}. */
@RunWith(AndroidJUnit4.class)
public final class DefaultAnnexBToAvccConverterTest {
@Test
public void convertAnnexBToAvcc_singleNalUnit() {
ByteBuffer in = generateFakeNalUnitData(1000);
// Add start code for the NAL unit.
in.put(0, (byte) 0);
in.put(1, (byte) 0);
in.put(2, (byte) 0);
in.put(3, (byte) 1);
AnnexBToAvccConverter annexBToAvccConverter = AnnexBToAvccConverter.DEFAULT;
annexBToAvccConverter.process(in);
// The start code should get replaced with the length of the NAL unit.
assertThat(in.getInt(0)).isEqualTo(996);
}
@Test
public void convertAnnexBToAvcc_twoNalUnits() {
ByteBuffer in = generateFakeNalUnitData(1000);
// Add start code for the first NAL unit.
in.put(0, (byte) 0);
in.put(1, (byte) 0);
in.put(2, (byte) 0);
in.put(3, (byte) 1);
// Add start code for the second NAL unit.
in.put(600, (byte) 0);
in.put(601, (byte) 0);
in.put(602, (byte) 0);
in.put(603, (byte) 1);
AnnexBToAvccConverter annexBToAvccConverter = AnnexBToAvccConverter.DEFAULT;
annexBToAvccConverter.process(in);
// Both the NAL units should have length headers.
assertThat(in.getInt(0)).isEqualTo(596);
assertThat(in.getInt(600)).isEqualTo(396);
}
@Test
public void convertAnnexBToAvcc_noNalUnit_outputSameAsInput() {
ByteBuffer input = generateFakeNalUnitData(1000);
ByteBuffer inputCopy = ByteBuffer.allocate(input.limit());
inputCopy.put(input);
input.rewind();
inputCopy.rewind();
AnnexBToAvccConverter annexBToAvccConverter = AnnexBToAvccConverter.DEFAULT;
annexBToAvccConverter.process(input);
assertThat(input).isEqualTo(inputCopy);
}
/** Returns {@link ByteBuffer} filled with random NAL unit data without start code. */
private static ByteBuffer generateFakeNalUnitData(int length) {
ByteBuffer buffer = ByteBuffer.allocateDirect(length);
for (int i = 0; i < length; i++) {
// Avoid anything resembling start codes (0x00000001) or emulation prevention byte (0x03).
buffer.put((byte) ((i % 250) + 5));
}
buffer.rewind();
return buffer;
}
}

View File

@ -0,0 +1,150 @@
/*
* 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 com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.media.MediaCodec.BufferInfo;
import android.util.Pair;
import androidx.media3.common.Format;
import androidx.media3.extractor.mp4.Mp4Extractor;
import androidx.media3.muxer.Mp4Muxer.TrackToken;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
/** End to end tests for {@link Mp4Muxer}. */
@RunWith(AndroidJUnit4.class)
public class Mp4MuxerEndToEndTest {
@Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
private Format format;
private String outputFilePath;
private FileOutputStream outputFileStream;
@Before
public void setUp() throws IOException {
outputFilePath = temporaryFolder.newFile("output.mp4").getPath();
outputFileStream = new FileOutputStream(outputFilePath);
format = MuxerTestUtil.getFakeVideoFormat();
}
@Test
public void createMp4File_addTrackAndMetadataButNoSamples_createsEmptyFile() throws IOException {
Mp4Muxer mp4Muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
mp4Muxer.addTrack(/* sortKey= */ 0, format);
mp4Muxer.setOrientation(90);
mp4Muxer.addMetadata("key", "value");
} finally {
mp4Muxer.close();
}
byte[] outputFileBytes = TestUtil.getByteArrayFromFilePath(outputFilePath);
assertThat(outputFileBytes).isEmpty();
}
@Test
public void createMp4File_withSameTracksOffset_matchesExpected() throws IOException {
Context context = ApplicationProvider.getApplicationContext();
Mp4Muxer mp4Muxer = new Mp4Muxer.Builder(outputFileStream).build();
Pair<ByteBuffer, BufferInfo> track1Sample1 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 100L);
Pair<ByteBuffer, BufferInfo> track1Sample2 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 200L);
Pair<ByteBuffer, BufferInfo> track2Sample1 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 100L);
Pair<ByteBuffer, BufferInfo> track2Sample2 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 300L);
try {
TrackToken track1 = mp4Muxer.addTrack(/* sortKey= */ 0, format);
mp4Muxer.writeSampleData(track1, track1Sample1.first, track1Sample1.second);
mp4Muxer.writeSampleData(track1, track1Sample2.first, track1Sample2.second);
// Add same track again but with different samples.
TrackToken track2 = mp4Muxer.addTrack(/* sortKey= */ 1, format);
mp4Muxer.writeSampleData(track2, track2Sample1.first, track2Sample1.second);
mp4Muxer.writeSampleData(track2, track2Sample2.first, track2Sample2.second);
} finally {
mp4Muxer.close();
}
// Presentation timestamps in dump file are:
// Track 1 Sample 1 = 0L
// Track 1 Sample 2 = 100L
// Track 2 Sample 1 = 0L
// Track 2 Sample 2 = 200L
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_same_tracks_offset.mp4"));
}
@Test
public void createMp4File_withDifferentTracksOffset_matchesExpected() throws IOException {
Context context = ApplicationProvider.getApplicationContext();
Mp4Muxer mp4Muxer = new Mp4Muxer.Builder(outputFileStream).build();
Pair<ByteBuffer, BufferInfo> track1Sample1 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 0L);
Pair<ByteBuffer, BufferInfo> track1Sample2 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 100L);
Pair<ByteBuffer, BufferInfo> track2Sample1 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 100L);
Pair<ByteBuffer, BufferInfo> track2Sample2 =
MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 200L);
try {
TrackToken track1 = mp4Muxer.addTrack(/* sortKey= */ 0, format);
mp4Muxer.writeSampleData(track1, track1Sample1.first, track1Sample1.second);
mp4Muxer.writeSampleData(track1, track1Sample2.first, track1Sample2.second);
// Add same track again but with different samples.
TrackToken track2 = mp4Muxer.addTrack(/* sortKey= */ 1, format);
mp4Muxer.writeSampleData(track2, track2Sample1.first, track2Sample1.second);
mp4Muxer.writeSampleData(track2, track2Sample2.first, track2Sample2.second);
} finally {
mp4Muxer.close();
}
// The presentation time of second track's first sample is forcefully changed to 0L.
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_different_tracks_offset.mp4"));
}
}

View File

@ -0,0 +1,288 @@
/*
* 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 android.content.Context;
import android.media.MediaCodec.BufferInfo;
import android.util.Pair;
import androidx.media3.common.Format;
import androidx.media3.extractor.mp4.Mp4Extractor;
import androidx.media3.muxer.Mp4Muxer.TrackToken;
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;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
/** Tests for metadata written by {@link Mp4Muxer}. */
@RunWith(AndroidJUnit4.class)
public class Mp4MuxerMetadataTest {
@Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
// Input files.
private static final String XMP_SAMPLE_DATA = "media/xmp/sample_datetime_xmp.xmp";
private Context context;
private String outputFilePath;
private FileOutputStream outputFileStream;
private Format format;
private Pair<ByteBuffer, BufferInfo> sampleAndSampleInfo;
@Before
public void setUp() throws Exception {
context = ApplicationProvider.getApplicationContext();
outputFilePath = temporaryFolder.newFile("output.mp4").getPath();
outputFileStream = new FileOutputStream(outputFilePath);
format = MuxerTestUtil.getFakeVideoFormat();
sampleAndSampleInfo = MuxerTestUtil.getFakeSampleAndSampleInfo(/* presentationTimeUs= */ 0L);
}
@Test
public void writeMp4File_orientationNotSet_setsOrientationTo0() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// No rotationDegrees field in output dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_0_orientation.mp4"));
}
@Test
public void writeMp4File_setOrientationTo90_setsOrientationTo90() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
muxer.setOrientation(90);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// rotationDegrees = 90 in the output dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_90_orientation.mp4"));
}
@Test
public void writeMp4File_setOrientationTo180_setsOrientationTo180() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
muxer.setOrientation(180);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// rotationDegrees = 180 in the output dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_180_orientation.mp4"));
}
@Test
public void writeMp4File_setOrientationTo270_setsOrientationTo270() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
muxer.setOrientation(270);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// rotationDegrees = 270 in the output dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_270_orientation.mp4"));
}
@Test
public void writeMp4File_setLocation_setsSameLocation() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
muxer.setLocation(33.0f, -120f);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// Xyz data in track metadata dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_location.mp4"));
}
@Test
public void writeMp4File_locationNotSet_setsLocationToNull() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// No xyz data in track metadata dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_null_location.mp4"));
}
@Test
public void writeMp4File_setFrameRate_setsSameFrameRate() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
muxer.setCaptureFps(120.0f);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputFilePath);
// android.capture.fps data in the track metadata dump.
DumpFileAsserts.assertOutput(
context,
fakeExtractorOutput,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_frame_rate.mp4"));
}
@Test
public void writeMp4File_addStringMetadata_matchesExpected() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
muxer.addMetadata("SomeStringKey", "Some Random String");
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
// TODO(b/270956881): Use FakeExtractorOutput once it starts dumping custom metadata from the
// meta box.
DumpableMp4Box dumpableBox =
new DumpableMp4Box(ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(outputFilePath)));
// The meta box should be present in the output MP4.
DumpFileAsserts.assertOutput(
context,
dumpableBox,
MuxerTestUtil.getExpectedDumpFilePath("mp4_with_string_metadata.mp4"));
}
@Test
public void writeMp4File_addFloatMetadata_matchesExpected() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
muxer.addMetadata("SomeStringKey", 10.0f);
TrackToken token = muxer.addTrack(/* sortKey= */ 0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
// TODO(b/270956881): Use FakeExtractorOutput once it starts dumping custom metadata from the
// meta box.
DumpableMp4Box dumpableBox =
new DumpableMp4Box(ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(outputFilePath)));
// The meta box should be present in the output MP4.
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("mp4_with_float_metadata.mp4"));
}
@Test
public void writeMp4File_addXmp_matchesExpected() throws Exception {
Mp4Muxer muxer = new Mp4Muxer.Builder(outputFileStream).build();
try {
muxer.setModificationTime(5000000);
Context context = ApplicationProvider.getApplicationContext();
byte[] xmpBytes = TestUtil.getByteArray(context, XMP_SAMPLE_DATA);
ByteBuffer xmp = ByteBuffer.wrap(xmpBytes);
muxer.addXmp(xmp);
xmp.rewind();
TrackToken token = muxer.addTrack(0, format);
muxer.writeSampleData(token, sampleAndSampleInfo.first, sampleAndSampleInfo.second);
} finally {
muxer.close();
}
// TODO(b/270956881): Use FakeExtractorOutput once it starts dumping uuid box.
DumpableMp4Box dumpableBox =
new DumpableMp4Box(ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(outputFilePath)));
// The uuid box should be present in the output MP4.
DumpFileAsserts.assertOutput(
context, dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("mp4_with_xmp.mp4"));
}
}

View File

@ -0,0 +1,74 @@
/*
* 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 android.media.MediaCodec;
import android.media.MediaCodec.BufferInfo;
import android.util.Pair;
import androidx.media3.common.Format;
import com.google.common.collect.ImmutableList;
import com.google.common.io.BaseEncoding;
import java.nio.ByteBuffer;
/** Utilities for muxer test cases. */
/* package */ class MuxerTestUtil {
private static final byte[] FAKE_CSD_0 =
BaseEncoding.base16().decode("0000000167F4000A919B2BF3CB3640000003004000000C83C4896580");
private static final byte[] FAKE_CSD_1 = BaseEncoding.base16().decode("0000000168EBE3C448");
private static final byte[] FAKE_H264_SAMPLE =
BaseEncoding.base16()
.decode(
"0000000167F4000A919B2BF3CB3640000003004000000C83C48965800000000168EBE3C448000001658884002BFFFEF5DBF32CAE4A43FF");
private static final String DUMP_FILE_OUTPUT_DIRECTORY = "muxerdumps";
private static final String DUMP_FILE_EXTENSION = "dump";
public static String getExpectedDumpFilePath(String originalFileName) {
return DUMP_FILE_OUTPUT_DIRECTORY + '/' + originalFileName + '.' + DUMP_FILE_EXTENSION;
}
public static Format getFakeVideoFormat() {
return new Format.Builder()
.setSampleMimeType("video/avc")
.setWidth(12)
.setHeight(10)
.setInitializationData(ImmutableList.of(FAKE_CSD_0, FAKE_CSD_1))
.build();
}
public static Format getFakeAudioFormat() {
return new Format.Builder()
.setSampleMimeType("audio/mp4a-latm")
.setSampleRate(40000)
.setChannelCount(2)
.build();
}
public static Pair<ByteBuffer, BufferInfo> getFakeSampleAndSampleInfo(long presentationTimeUs) {
ByteBuffer sampleDirectBuffer = ByteBuffer.allocateDirect(FAKE_H264_SAMPLE.length);
sampleDirectBuffer.put(FAKE_H264_SAMPLE);
sampleDirectBuffer.rewind();
BufferInfo bufferInfo = new BufferInfo();
bufferInfo.presentationTimeUs = presentationTimeUs;
bufferInfo.flags = MediaCodec.BUFFER_FLAG_KEY_FRAME;
bufferInfo.size = FAKE_H264_SAMPLE.length;
return new Pair<>(sampleDirectBuffer, bufferInfo);
}
private MuxerTestUtil() {}
}