Commit 28641637 by sheenachhabra Committed by Ian Baker

Open source muxer module

PiperOrigin-RevId: 526683141
parent e0511809
Showing with 1704 additions and 0 deletions
......@@ -85,6 +85,9 @@ project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensi
include modulePrefix + 'library-effect'
project(modulePrefix + 'library-effect').projectDir = new File(rootDir, 'library/effect')
include modulePrefix + 'library-muxer'
project(modulePrefix + 'library-muxer').projectDir = new File(rootDir, 'library/muxer')
include modulePrefix + 'library-transformer'
project(modulePrefix + 'library-transformer').projectDir = new File(rootDir, 'library/transformer')
......
# 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 'com.google.android.exoplayer:exoplayer-muxer:2.X.X'
```
where `2.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]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
// 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.exoplayerSettingsDir/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 '../../testdata/src/test/assets/'
test.assets.srcDir '../../testdata/src/test/assets/'
}
}
ext {
javadocTitle = 'Muxer module'
}
dependencies {
implementation project(modulePrefix + 'library-common')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'library-extractor')
testImplementation project(modulePrefix + 'robolectricutils')
testImplementation project(modulePrefix + 'testutils')
testImplementation project(modulePrefix + 'testdata')
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 + 'testutils')
androidTestImplementation project(modulePrefix + 'library-extractor')
}
apply from: '../../javadoc_library.gradle'
ext {
releaseArtifactId = 'exoplayer-muxer'
releaseDescription = 'The ExoPlayer library muxer module.'
}
apply from: '../../publish.gradle'
<?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>
/*
* 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;
}
}
/*
* 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.test.core.app.ApplicationProvider;
import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor;
import com.google.android.exoplayer2.testutil.DumpFileAsserts;
import com.google.android.exoplayer2.testutil.FakeExtractorOutput;
import com.google.android.exoplayer2.testutil.TestUtil;
import com.google.android.exoplayer2.util.MediaFormatUtil;
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();
}
}
<?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>
/*
* 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.android.exoplayer2.util.Assertions.checkArgument;
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).
*/
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);
}
/*
* 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();
}
}
/*
* 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;
}
}
/*
* 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() {}
}
/*
* 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 com.google.android.exoplayer2.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;
}
}
/*
* 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;
}
}
/*
* 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 com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.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();
}
}
/*
* 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 com.google.android.exoplayer2.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 com.google.android.exoplayer2.Format;
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>
*/
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();
}
}
/*
* 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;
}
}
/*
* 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 com.google.android.exoplayer2.util.NonNullApi;
<?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>
/*
* 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.android.exoplayer2.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")));
}
}
/*
* 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;
}
}
/*
* 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.muxer.Mp4Muxer.TrackToken;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor;
import com.google.android.exoplayer2.testutil.DumpFileAsserts;
import com.google.android.exoplayer2.testutil.FakeExtractorOutput;
import com.google.android.exoplayer2.testutil.TestUtil;
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"));
}
}
/*
* 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 com.google.android.exoplayer2.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() {}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment