Allow mismatching H.264/AVC level for trimming or resuming

MediaCodec docs already allude to potentially mismatching H.264 level
between container and bitstream. Relax the initialization data check to
reflect this.

PiperOrigin-RevId: 608942322
This commit is contained in:
Googler 2024-02-21 04:18:02 -08:00 committed by Copybara-Service
parent a82d7e7098
commit dca7bae416
7 changed files with 221 additions and 4 deletions

View file

@ -10,6 +10,7 @@
was made. was made.
* Add workaround for exception thrown due to `MediaMuxer` not supporting * Add workaround for exception thrown due to `MediaMuxer` not supporting
negative presentation timestamps before API 30. negative presentation timestamps before API 30.
* Relax trim optimization H.264 level checks.
* Track Selection: * Track Selection:
* Extractors: * Extractors:
* Audio: * Audio:

View file

@ -74,6 +74,9 @@ public final class AndroidTestUtil {
public static final String MP4_TRIM_OPTIMIZATION_URI_STRING = public static final String MP4_TRIM_OPTIMIZATION_URI_STRING =
"asset:///media/mp4/internal_emulator_transformer_output.mp4"; "asset:///media/mp4/internal_emulator_transformer_output.mp4";
public static final String MP4_TRIM_OPTIMIZATION_PIXEL_URI_STRING =
"asset:///media/mp4/pixel7_videoOnly_cleaned.mp4";
public static final String MP4_ASSET_URI_STRING = "asset:///media/mp4/sample.mp4"; public static final String MP4_ASSET_URI_STRING = "asset:///media/mp4/sample.mp4";
public static final Format MP4_ASSET_FORMAT = public static final Format MP4_ASSET_FORMAT =
new Format.Builder() new Format.Builder()

View file

@ -28,9 +28,13 @@ import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_SEF_URI_STRI
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_FORMAT; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_FORMAT;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_URI_STRING; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_URI_STRING;
import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION_PIXEL_URI_STRING;
import static androidx.media3.transformer.AndroidTestUtil.recordTestSkipped; import static androidx.media3.transformer.AndroidTestUtil.recordTestSkipped;
import static androidx.media3.transformer.ExportResult.CONVERSION_PROCESS_TRANSMUXED_AND_TRANSCODED;
import static androidx.media3.transformer.ExportResult.OPTIMIZATION_SUCCEEDED;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assume.assumeFalse; import static org.junit.Assume.assumeFalse;
import static org.junit.Assume.assumeTrue;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
@ -382,4 +386,42 @@ public class ExportTest {
.build() .build()
.run(testId, editedMediaItem); .run(testId, editedMediaItem);
} }
@Test
public void clippedMedia_trimOptimizationEnabled_pixel7Plus_completesWithOptimizationApplied()
throws Exception {
String testId =
TAG + "_clippedMedia_trimOptimizationEnabled_pixel_completesWithOptimizationApplied";
Context context = ApplicationProvider.getApplicationContext();
// Devices with Tensor G2 & G3 chipsets should work, but Pixel 7a is flaky.
assumeTrue(
Ascii.toLowerCase(Util.MODEL).contains("pixel")
&& (Ascii.toLowerCase(Util.MODEL).contains("7")
|| Ascii.toLowerCase(Util.MODEL).contains("8")
|| Ascii.toLowerCase(Util.MODEL).contains("fold")
|| Ascii.toLowerCase(Util.MODEL).contains("tablet")));
assumeFalse(Ascii.toLowerCase(Util.MODEL).contains("7a"));
Transformer transformer =
new Transformer.Builder(context).experimentalSetTrimOptimizationEnabled(true).build();
MediaItem mediaItem =
new MediaItem.Builder()
.setUri(MP4_TRIM_OPTIMIZATION_PIXEL_URI_STRING)
.setClippingConfiguration(
new MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(500)
.setEndPositionMs(1200)
.build())
.build();
EditedMediaItem editedMediaItem = new EditedMediaItem.Builder(mediaItem).build();
ExportTestResult result =
new TransformerAndroidTestRunner.Builder(context, transformer)
.build()
.run(testId, editedMediaItem);
assertThat(result.exportResult.optimizationResult).isEqualTo(OPTIMIZATION_SUCCEEDED);
assertThat(result.exportResult.durationMs).isAtMost(700);
assertThat(result.exportResult.videoConversionProcess)
.isEqualTo(CONVERSION_PROCESS_TRANSMUXED_AND_TRANSCODED);
}
} }

View file

@ -36,6 +36,7 @@ import androidx.media3.common.Format;
import androidx.media3.common.Metadata; import androidx.media3.common.Metadata;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.container.NalUnitUtil;
import androidx.media3.effect.DebugTraceUtil; import androidx.media3.effect.DebugTraceUtil;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.io.File; import java.io.File;
@ -44,6 +45,8 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
@ -148,6 +151,72 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
abortScheduledExecutorService = Util.newSingleThreadScheduledExecutor(TIMER_THREAD_NAME); abortScheduledExecutorService = Util.newSingleThreadScheduledExecutor(TIMER_THREAD_NAME);
} }
/**
* Returns whether the initialization data of two video formats can be muxed together.
*
* @param existingVideoTrackFormat The starting video format to compare.
* @param newVideoTrackFormat The candidate format of the video bitstream to be appended after the
* existing format.
* @return {@code true} if both input formats can be muxed together in the same container.
*/
public static boolean isInitializationDataCompatible(
Format existingVideoTrackFormat, Format newVideoTrackFormat) {
if (existingVideoTrackFormat.initializationDataEquals(newVideoTrackFormat)) {
return true;
}
if (!Objects.equals(newVideoTrackFormat.sampleMimeType, MimeTypes.VIDEO_H264)
|| !Objects.equals(existingVideoTrackFormat.sampleMimeType, MimeTypes.VIDEO_H264)) {
return false;
}
// For H.264 we allow a different level number. This is not spec-compliant, but such videos are
// already common on Android. See, for example: {@link MediaFormat#KEY_LEVEL}.
// Android players are advised to support level mismatch between container
// and bitstream: see {@link android.media.MediaExtractor#getTrackFormat(int)}.
if (newVideoTrackFormat.initializationData.size() != 2
|| existingVideoTrackFormat.initializationData.size() != 2) {
return false;
}
// Check picture parameter sets match.
if (!Arrays.equals(
newVideoTrackFormat.initializationData.get(1),
existingVideoTrackFormat.initializationData.get(1))) {
return false;
}
// Allow level_idc to be lower in the new stream.
// Note: the SPS doesn't need to be unescaped because it's not possible to have two
// consecutive 0 bytes at/before level_idc.
byte[] newSps = newVideoTrackFormat.initializationData.get(0);
byte[] existingSps = existingVideoTrackFormat.initializationData.get(0);
// Skip 3 bytes: NAL unit type, profile, and reserved fields.
int spsLevelIndex = NalUnitUtil.NAL_START_CODE.length + 3;
if (spsLevelIndex >= newSps.length) {
return false;
}
if (newSps.length != existingSps.length) {
return false;
}
for (int i = 0; i < newSps.length; i++) {
if (i != spsLevelIndex && newSps[i] != existingSps[i]) {
return false;
}
}
for (int i = 0; i < NalUnitUtil.NAL_START_CODE.length; i++) {
if (newSps[i] != NalUnitUtil.NAL_START_CODE[i]) {
return false;
}
}
int nalUnitTypeMask = 0x1F;
if ((newSps[NalUnitUtil.NAL_START_CODE.length] & nalUnitTypeMask)
!= NalUnitUtil.NAL_UNIT_TYPE_SPS) {
return false;
}
// Check that H.264 profile is non-zero.
if (newSps[NalUnitUtil.NAL_START_CODE.length + 1] == 0) {
return false;
}
return true;
}
/** /**
* Changes {@link MuxerMode} to {@link #MUXER_MODE_APPEND}. * Changes {@link MuxerMode} to {@link #MUXER_MODE_APPEND}.
* *
@ -255,7 +324,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
checkArgument(areEqual(existingFormat.sampleMimeType, format.sampleMimeType)); checkArgument(areEqual(existingFormat.sampleMimeType, format.sampleMimeType));
checkArgument(existingFormat.width == format.width); checkArgument(existingFormat.width == format.width);
checkArgument(existingFormat.height == format.height); checkArgument(existingFormat.height == format.height);
checkArgument(existingFormat.initializationDataEquals(format)); checkArgument(isInitializationDataCompatible(existingFormat, format));
} else if (trackType == C.TRACK_TYPE_AUDIO) { } else if (trackType == C.TRACK_TYPE_AUDIO) {
checkState(contains(trackTypeToInfo, C.TRACK_TYPE_AUDIO)); checkState(contains(trackTypeToInfo, C.TRACK_TYPE_AUDIO));
TrackInfo audioTrackInfo = trackTypeToInfo.get(C.TRACK_TYPE_AUDIO); TrackInfo audioTrackInfo = trackTypeToInfo.get(C.TRACK_TYPE_AUDIO);

View file

@ -1508,10 +1508,10 @@ public final class Transformer {
} }
private boolean doesFormatsMatch(Mp4Info mediaItemInfo, EditedMediaItem firstMediaItem) { private boolean doesFormatsMatch(Mp4Info mediaItemInfo, EditedMediaItem firstMediaItem) {
Format videoFormat = checkNotNull(remuxingMuxerWrapper).getTrackFormat(C.TRACK_TYPE_VIDEO);
boolean videoFormatMatches = boolean videoFormatMatches =
checkNotNull(remuxingMuxerWrapper) MuxerWrapper.isInitializationDataCompatible(
.getTrackFormat(C.TRACK_TYPE_VIDEO) videoFormat, checkNotNull(mediaItemInfo.videoFormat));
.initializationDataEquals(checkNotNull(mediaItemInfo.videoFormat));
boolean audioFormatMatches = boolean audioFormatMatches =
mediaItemInfo.audioFormat == null mediaItemInfo.audioFormat == null
|| firstMediaItem.removeAudio || firstMediaItem.removeAudio

View file

@ -17,6 +17,7 @@ package androidx.media3.transformer;
import static androidx.media3.common.MimeTypes.AUDIO_AAC; import static androidx.media3.common.MimeTypes.AUDIO_AAC;
import static androidx.media3.common.MimeTypes.VIDEO_H264; import static androidx.media3.common.MimeTypes.VIDEO_H264;
import static androidx.media3.common.MimeTypes.VIDEO_H265;
import static androidx.media3.transformer.MuxerWrapper.MUXER_MODE_DEFAULT; import static androidx.media3.transformer.MuxerWrapper.MUXER_MODE_DEFAULT;
import static androidx.media3.transformer.MuxerWrapper.MUXER_MODE_MUX_PARTIAL; import static androidx.media3.transformer.MuxerWrapper.MUXER_MODE_MUX_PARTIAL;
import static androidx.media3.transformer.TestUtil.getDumpFileName; import static androidx.media3.transformer.TestUtil.getDumpFileName;
@ -335,6 +336,107 @@ public class MuxerWrapperTest {
assertThat(muxerWrapper.isEnded()).isFalse(); assertThat(muxerWrapper.isEnded()).isFalse();
} }
@Test
public void isInitializationDataCompatible_h265_differentCSD_returnsFalse() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H265)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 0}))
.build();
Format otherFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H265)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 4}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isFalse();
}
@Test
public void isInitializationDataCompatible_h265_matchingCSD_returnsTrue() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H265)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 4}))
.build();
Format otherFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H265)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 4}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isTrue();
}
@Test
public void isInitializationDataCompatible_h264_matchingCSD_returnsTrue() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 4}))
.build();
Format otherFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(ImmutableList.of(new byte[] {1, 2, 3, 4}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isTrue();
}
@Test
public void isInitializationDataCompatible_h264_ignoresLevel_returnsTrue() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 100, 0, 40}, new byte[] {0, 0, 0, 1}))
.build();
Format otherFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 100, 0, 41}, new byte[] {0, 0, 0, 1}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isTrue();
}
@Test
public void isInitializationDataCompatible_h264_mismatchProfile_returnsFalse() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 110, 0, 41}, new byte[] {0, 0, 0, 1}))
.build();
Format otherFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 100, 0, 41}, new byte[] {0, 0, 0, 1}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isFalse();
}
@Test
public void isInitializationDataCompatible_h264_missingMimeType_returnsFalse() {
Format existingFormat =
new Format.Builder()
.setSampleMimeType(VIDEO_H264)
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 100, 0, 40}, new byte[] {0, 0, 0, 1}))
.build();
Format otherFormat =
new Format.Builder()
.setInitializationData(
ImmutableList.of(new byte[] {0, 0, 0, 1, 103, 100, 0, 41}, new byte[] {0, 0, 0, 1}))
.build();
assertThat(MuxerWrapper.isInitializationDataCompatible(existingFormat, otherFormat)).isFalse();
}
private static final class NoOpMuxerListenerImpl implements MuxerWrapper.Listener { private static final class NoOpMuxerListenerImpl implements MuxerWrapper.Listener {
@Override @Override