Frame extraction support changing MediaItem

Add FrameExtraction.setMediaItem() method
Prefer non-vendor software codecs by default because
vendor codecs sometimes fail when we attempt reuse.

PiperOrigin-RevId: 703486235
This commit is contained in:
dancho 2024-12-06 06:54:42 -08:00 committed by Copybara-Service
parent b816e2f284
commit 1d2ffcb165
4 changed files with 220 additions and 113 deletions

View File

@ -258,7 +258,8 @@ public final class MediaCodecUtil {
/** /**
* Returns a copy of the provided decoder list sorted such that software decoders are listed * Returns a copy of the provided decoder list sorted such that software decoders are listed
* first. * first. Break ties by listing non-{@link MediaCodecInfo#vendor} decoders first, due to issues
* with decoder reuse with some software vendor codecs. See b/382447848.
* *
* <p>The returned list is not modifiable. * <p>The returned list is not modifiable.
*/ */
@ -266,7 +267,9 @@ public final class MediaCodecUtil {
public static List<MediaCodecInfo> getDecoderInfosSortedBySoftwareOnly( public static List<MediaCodecInfo> getDecoderInfosSortedBySoftwareOnly(
List<MediaCodecInfo> decoderInfos) { List<MediaCodecInfo> decoderInfos) {
decoderInfos = new ArrayList<>(decoderInfos); decoderInfos = new ArrayList<>(decoderInfos);
sortByScore(decoderInfos, decoderInfo -> decoderInfo.softwareOnly ? 1 : 0); sortByScore(
decoderInfos,
decoderInfo -> (decoderInfo.softwareOnly ? 2 : 0) + (decoderInfo.vendor ? 0 : 1));
return ImmutableList.copyOf(decoderInfos); return ImmutableList.copyOf(decoderInfos);
} }

View File

@ -16,6 +16,7 @@
package androidx.media3.transformer; package androidx.media3.transformer;
import static androidx.media3.common.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND; import static androidx.media3.common.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND;
import static androidx.media3.common.PlaybackException.ERROR_CODE_SETUP_REQUIRED;
import static androidx.media3.exoplayer.SeekParameters.CLOSEST_SYNC; import static androidx.media3.exoplayer.SeekParameters.CLOSEST_SYNC;
import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap; import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap;
import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap; import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap;
@ -30,6 +31,7 @@ import android.app.Instrumentation;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.NullableType; import androidx.media3.common.util.NullableType;
import androidx.media3.effect.Presentation; import androidx.media3.effect.Presentation;
@ -92,10 +94,8 @@ public class FrameExtractorTest {
public void extractFrame_oneFrame_returnsNearest() throws Exception { public void extractFrame_oneFrame_returnsNearest() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 8_500); ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 8_500);
Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS); Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS);
@ -120,10 +120,10 @@ public class FrameExtractorTest {
public void extractFrame_oneFrameWithPresentationEffect_returnsScaledFrame() throws Exception { public void extractFrame_oneFrameWithPresentationEffect_returnsScaledFrame() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(
MediaItem.fromUri(FILE_PATH), MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of(Presentation.createForHeight(180))); /* effects= */ ImmutableList.of(Presentation.createForHeight(180)));
ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 8_500); ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 8_500);
Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS); Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS);
@ -148,10 +148,8 @@ public class FrameExtractorTest {
public void extractFrame_pastDuration_returnsLastFrame() throws Exception { public void extractFrame_pastDuration_returnsLastFrame() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 200_000); ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 200_000);
Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS); Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS);
@ -177,10 +175,8 @@ public class FrameExtractorTest {
public void extractFrame_repeatedPositionMs_returnsTheSameFrame() throws Exception { public void extractFrame_repeatedPositionMs_returnsTheSameFrame() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ImmutableList<Long> requestedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 34L, 34L); ImmutableList<Long> requestedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 34L, 34L);
ImmutableList<Long> expectedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 66L, 66L); ImmutableList<Long> expectedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 66L, 66L);
List<ListenableFuture<Frame>> frameFutures = new ArrayList<>(); List<ListenableFuture<Frame>> frameFutures = new ArrayList<>();
@ -217,9 +213,8 @@ public class FrameExtractorTest {
context, context,
new ExperimentalFrameExtractor.Configuration.Builder() new ExperimentalFrameExtractor.Configuration.Builder()
.setSeekParameters(CLOSEST_SYNC) .setSeekParameters(CLOSEST_SYNC)
.build(), .build());
MediaItem.fromUri(FILE_PATH), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
/* effects= */ ImmutableList.of());
ImmutableList<Long> requestedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 34L, 34L); ImmutableList<Long> requestedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 34L, 34L);
ImmutableList<Long> expectedFramePositionsMs = ImmutableList.of(0L, 0L, 0L, 0L, 0L); ImmutableList<Long> expectedFramePositionsMs = ImmutableList.of(0L, 0L, 0L, 0L, 0L);
List<ListenableFuture<Frame>> frameFutures = new ArrayList<>(); List<ListenableFuture<Frame>> frameFutures = new ArrayList<>();
@ -253,10 +248,8 @@ public class FrameExtractorTest {
public void extractFrame_randomAccess_returnsCorrectFrames() throws Exception { public void extractFrame_randomAccess_returnsCorrectFrames() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000); ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000);
ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000); ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000);
@ -284,9 +277,8 @@ public class FrameExtractorTest {
context, context,
new ExperimentalFrameExtractor.Configuration.Builder() new ExperimentalFrameExtractor.Configuration.Builder()
.setSeekParameters(CLOSEST_SYNC) .setSeekParameters(CLOSEST_SYNC)
.build(), .build());
MediaItem.fromUri(FILE_PATH), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000); ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000);
ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000); ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000);
@ -314,10 +306,8 @@ public class FrameExtractorTest {
String filePath = "asset:///nonexistent"; String filePath = "asset:///nonexistent";
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(filePath), /* effects= */ ImmutableList.of());
MediaItem.fromUri(filePath),
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frame0 = frameExtractor.getFrame(/* positionMs= */ 0); ListenableFuture<Frame> frame0 = frameExtractor.getFrame(/* positionMs= */ 0);
@ -328,14 +318,27 @@ public class FrameExtractorTest {
.isEqualTo(ERROR_CODE_IO_FILE_NOT_FOUND); .isEqualTo(ERROR_CODE_IO_FILE_NOT_FOUND);
} }
@Test
public void getFrame_withoutMediaItem_throws() {
frameExtractor =
new ExperimentalFrameExtractor(
context, new ExperimentalFrameExtractor.Configuration.Builder().build());
ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 8_500);
ExecutionException thrown =
assertThrows(ExecutionException.class, () -> frameFuture.get(TIMEOUT_SECONDS, SECONDS));
assertThat(thrown).hasCauseThat().isInstanceOf(PlaybackException.class);
assertThat(((PlaybackException) thrown.getCause()).errorCode)
.isEqualTo(ERROR_CODE_SETUP_REQUIRED);
}
@Test @Test
public void extractFrame_oneFrame_completesViaCallback() throws Exception { public void extractFrame_oneFrame_completesViaCallback() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
AtomicReference<@NullableType Frame> frameAtomicReference = new AtomicReference<>(); AtomicReference<@NullableType Frame> frameAtomicReference = new AtomicReference<>();
AtomicReference<@NullableType Throwable> throwableAtomicReference = new AtomicReference<>(); AtomicReference<@NullableType Throwable> throwableAtomicReference = new AtomicReference<>();
ConditionVariable frameReady = new ConditionVariable(); ConditionVariable frameReady = new ConditionVariable();
@ -373,10 +376,8 @@ public class FrameExtractorTest {
public void frameExtractor_releaseOnPlayerLooper_returns() { public void frameExtractor_releaseOnPlayerLooper_returns() {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
instrumentation.runOnMainSync(frameExtractor::release); instrumentation.runOnMainSync(frameExtractor::release);
@ -387,10 +388,9 @@ public class FrameExtractorTest {
public void extractFrame_oneFrameRotated_returnsFrameInCorrectOrientation() throws Exception { public void extractFrame_oneFrameRotated_returnsFrameInCorrectOrientation() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(
MediaItem.fromUri(MP4_TRIM_OPTIMIZATION_270.uri), MediaItem.fromUri(MP4_TRIM_OPTIMIZATION_270.uri), /* effects= */ ImmutableList.of());
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 0); ListenableFuture<Frame> frameFuture = frameExtractor.getFrame(/* positionMs= */ 0);
Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS); Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS);
@ -415,10 +415,8 @@ public class FrameExtractorTest {
public void extractFrame_randomAccessWithCancellation_returnsCorrectFrames() throws Exception { public void extractFrame_randomAccessWithCancellation_returnsCorrectFrames() throws Exception {
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000); ListenableFuture<Frame> frame5 = frameExtractor.getFrame(/* positionMs= */ 5_000);
ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000); ListenableFuture<Frame> frame3 = frameExtractor.getFrame(/* positionMs= */ 3_000);
@ -440,4 +438,39 @@ public class FrameExtractorTest {
.renderedOutputBufferCount) .renderedOutputBufferCount)
.isEqualTo(4); .isEqualTo(4);
} }
@Test
public void extractFrame_changeMediaItem_extractsFrameFromTheCorrectItem() throws Exception {
frameExtractor =
new ExperimentalFrameExtractor(
context, new ExperimentalFrameExtractor.Configuration.Builder().build());
frameExtractor.setMediaItem(
MediaItem.fromUri(MP4_TRIM_OPTIMIZATION_270.uri), /* effects= */ ImmutableList.of());
ListenableFuture<Frame> frameFutureFirstItem = frameExtractor.getFrame(/* positionMs= */ 0);
frameExtractor.setMediaItem(MediaItem.fromUri(FILE_PATH), /* effects= */ ImmutableList.of());
ListenableFuture<Frame> frameFutureSecondItem =
frameExtractor.getFrame(/* positionMs= */ 8_500);
Frame frameFirstItem = frameFutureFirstItem.get(TIMEOUT_SECONDS, SECONDS);
Bitmap actualBitmapFirstItem = frameFirstItem.bitmap;
Bitmap expectedBitmapFirstItem =
readBitmap(
/* assetString= */ GOLDEN_ASSET_FOLDER_PATH
+ "internal_emulator_transformer_output_180_rotated_0.000.png");
maybeSaveTestBitmap(
testId, /* bitmapLabel= */ "firstItem", actualBitmapFirstItem, /* path= */ null);
Frame frameSecondItem = frameFutureSecondItem.get(TIMEOUT_SECONDS, SECONDS);
Bitmap actualBitmapSecondItem = frameSecondItem.bitmap;
Bitmap expectedBitmapSecondItem =
readBitmap(
/* assetString= */ GOLDEN_ASSET_FOLDER_PATH
+ "sample_with_increasing_timestamps_360p_8.531.png");
maybeSaveTestBitmap(
testId, /* bitmapLabel= */ "secondItem", actualBitmapSecondItem, /* path= */ null);
assertThat(frameFirstItem.presentationTimeMs).isEqualTo(0);
assertBitmapsAreSimilar(expectedBitmapFirstItem, actualBitmapFirstItem, PSNR_THRESHOLD);
assertThat(frameSecondItem.presentationTimeMs).isEqualTo(8_531);
assertBitmapsAreSimilar(expectedBitmapSecondItem, actualBitmapSecondItem, PSNR_THRESHOLD);
}
} }

View File

@ -23,6 +23,7 @@ import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap
import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap; import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap;
import static androidx.media3.test.utils.TestUtil.assertBitmapsAreSimilar; import static androidx.media3.test.utils.TestUtil.assertBitmapsAreSimilar;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_COLOR_TEST_1080P_HLG10; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_COLOR_TEST_1080P_HLG10;
import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION_270;
import static androidx.media3.transformer.mh.HdrCapabilitiesUtil.assumeDeviceSupportsOpenGlToneMapping; import static androidx.media3.transformer.mh.HdrCapabilitiesUtil.assumeDeviceSupportsOpenGlToneMapping;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
@ -56,8 +57,8 @@ public class FrameExtractorHdrTest {
"test-generated-goldens/sample_mp4_first_frame/electrical_colors/tone_map_hlg_to_sdr.png"; "test-generated-goldens/sample_mp4_first_frame/electrical_colors/tone_map_hlg_to_sdr.png";
// File names in test-generated-goldens/FrameExtractorTest end with the presentation time of the // File names in test-generated-goldens/FrameExtractorTest end with the presentation time of the
// extracted frame in seconds and milliseconds (_0.000 for 0s ; _1.567 for 1.567 seconds). // extracted frame in seconds and milliseconds (_0.000 for 0s ; _1.567 for 1.567 seconds).
private static final String EXTRACT_HLG_PNG_ASSET_PATH = private static final String GOLDEN_ASSET_FOLDER_PATH =
"test-generated-goldens/FrameExtractorTest/hlg10-color-test_0.000.png"; "test-generated-goldens/FrameExtractorTest/";
private static final long TIMEOUT_SECONDS = 10; private static final long TIMEOUT_SECONDS = 10;
private static final float PSNR_THRESHOLD = 25f; private static final float PSNR_THRESHOLD = 25f;
@ -85,10 +86,9 @@ public class FrameExtractorHdrTest {
assumeDeviceSupportsOpenGlToneMapping(testId, MP4_ASSET_COLOR_TEST_1080P_HLG10.videoFormat); assumeDeviceSupportsOpenGlToneMapping(testId, MP4_ASSET_COLOR_TEST_1080P_HLG10.videoFormat);
frameExtractor = frameExtractor =
new ExperimentalFrameExtractor( new ExperimentalFrameExtractor(
context, context, new ExperimentalFrameExtractor.Configuration.Builder().build());
new ExperimentalFrameExtractor.Configuration.Builder().build(), frameExtractor.setMediaItem(
MediaItem.fromUri(MP4_ASSET_COLOR_TEST_1080P_HLG10.uri), MediaItem.fromUri(MP4_ASSET_COLOR_TEST_1080P_HLG10.uri), /* effects= */ ImmutableList.of());
/* effects= */ ImmutableList.of());
ListenableFuture<ExperimentalFrameExtractor.Frame> frameFuture = ListenableFuture<ExperimentalFrameExtractor.Frame> frameFuture =
frameExtractor.getFrame(/* positionMs= */ 0); frameExtractor.getFrame(/* positionMs= */ 0);
@ -111,16 +111,17 @@ public class FrameExtractorHdrTest {
context, context,
new ExperimentalFrameExtractor.Configuration.Builder() new ExperimentalFrameExtractor.Configuration.Builder()
.setExtractHdrFrames(true) .setExtractHdrFrames(true)
.build(), .build());
MediaItem.fromUri(MP4_ASSET_COLOR_TEST_1080P_HLG10.uri), frameExtractor.setMediaItem(
/* effects= */ ImmutableList.of()); MediaItem.fromUri(MP4_ASSET_COLOR_TEST_1080P_HLG10.uri), /* effects= */ ImmutableList.of());
ListenableFuture<ExperimentalFrameExtractor.Frame> frameFuture = ListenableFuture<ExperimentalFrameExtractor.Frame> frameFuture =
frameExtractor.getFrame(/* positionMs= */ 0); frameExtractor.getFrame(/* positionMs= */ 0);
ExperimentalFrameExtractor.Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS); ExperimentalFrameExtractor.Frame frame = frameFuture.get(TIMEOUT_SECONDS, SECONDS);
Bitmap actualBitmap = frame.bitmap; Bitmap actualBitmap = frame.bitmap;
Bitmap actualBitmapDefaultColorSpace = removeColorSpace(actualBitmap); Bitmap actualBitmapDefaultColorSpace = removeColorSpace(actualBitmap);
Bitmap expectedBitmap = readBitmap(EXTRACT_HLG_PNG_ASSET_PATH); Bitmap expectedBitmap =
readBitmap(/* assetString= */ GOLDEN_ASSET_FOLDER_PATH + "hlg10-color-test_0.000.png");
maybeSaveTestBitmap( maybeSaveTestBitmap(
testId, testId,
/* bitmapLabel= */ "actualBitmapDefaultColorSpace", /* bitmapLabel= */ "actualBitmapDefaultColorSpace",
@ -133,6 +134,45 @@ public class FrameExtractorHdrTest {
assertBitmapsAreSimilar(expectedBitmap, actualBitmap, PSNR_THRESHOLD); assertBitmapsAreSimilar(expectedBitmap, actualBitmap, PSNR_THRESHOLD);
} }
@Test
public void
extractFrame_changeMediaItemFromHdrToSdrWithToneMapping_extractsFrameFromTheCorrectItem()
throws Exception {
assumeDeviceSupportsOpenGlToneMapping(testId, MP4_ASSET_COLOR_TEST_1080P_HLG10.videoFormat);
frameExtractor =
new ExperimentalFrameExtractor(
context, new ExperimentalFrameExtractor.Configuration.Builder().build());
frameExtractor.setMediaItem(
MediaItem.fromUri(MP4_ASSET_COLOR_TEST_1080P_HLG10.uri), /* effects= */ ImmutableList.of());
ListenableFuture<ExperimentalFrameExtractor.Frame> frameFutureFirstItem =
frameExtractor.getFrame(/* positionMs= */ 0);
frameExtractor.setMediaItem(
MediaItem.fromUri(MP4_TRIM_OPTIMIZATION_270.uri), /* effects= */ ImmutableList.of());
ListenableFuture<ExperimentalFrameExtractor.Frame> frameFutureSecondItem =
frameExtractor.getFrame(/* positionMs= */ 0);
ExperimentalFrameExtractor.Frame frameFirstItem =
frameFutureFirstItem.get(TIMEOUT_SECONDS, SECONDS);
Bitmap actualBitmapFirstItem = frameFirstItem.bitmap;
Bitmap expectedBitmapFirstItem = readBitmap(TONE_MAP_HLG_TO_SDR_PNG_ASSET_PATH);
maybeSaveTestBitmap(
testId, /* bitmapLabel= */ "firstItem", actualBitmapFirstItem, /* path= */ null);
ExperimentalFrameExtractor.Frame frameSecondItem =
frameFutureSecondItem.get(TIMEOUT_SECONDS, SECONDS);
Bitmap actualBitmapSecondItem = frameSecondItem.bitmap;
Bitmap expectedBitmapSecondItem =
readBitmap(
/* assetString= */ GOLDEN_ASSET_FOLDER_PATH
+ "internal_emulator_transformer_output_180_rotated_0.000.png");
maybeSaveTestBitmap(
testId, /* bitmapLabel= */ "secondItem", actualBitmapSecondItem, /* path= */ null);
assertThat(frameFirstItem.presentationTimeMs).isEqualTo(0);
assertBitmapsAreSimilar(expectedBitmapFirstItem, actualBitmapFirstItem, PSNR_THRESHOLD);
assertThat(frameSecondItem.presentationTimeMs).isEqualTo(0);
assertBitmapsAreSimilar(expectedBitmapSecondItem, actualBitmapSecondItem, PSNR_THRESHOLD);
}
/** /**
* Copy the contents of the input {@link Bitmap} into a {@link Bitmap} with {@link * Copy the contents of the input {@link Bitmap} into a {@link Bitmap} with {@link
* Bitmap.Config#RGBA_F16} config and default {@link ColorSpace}. * Bitmap.Config#RGBA_F16} config and default {@link ColorSpace}.

View File

@ -24,11 +24,13 @@ import static androidx.media3.common.ColorInfo.SDR_BT709_LIMITED;
import static androidx.media3.common.ColorInfo.isTransferHdr; import static androidx.media3.common.ColorInfo.isTransferHdr;
import static androidx.media3.common.PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK; import static androidx.media3.common.PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK;
import static androidx.media3.common.PlaybackException.ERROR_CODE_INVALID_STATE; import static androidx.media3.common.PlaybackException.ERROR_CODE_INVALID_STATE;
import static androidx.media3.common.PlaybackException.ERROR_CODE_SETUP_REQUIRED;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.GlUtil.createRgb10A2Texture; import static androidx.media3.common.util.GlUtil.createRgb10A2Texture;
import static androidx.media3.common.util.Util.SDK_INT; import static androidx.media3.common.util.Util.SDK_INT;
import static androidx.media3.common.util.Util.usToMs; import static androidx.media3.common.util.Util.usToMs;
import static com.google.common.util.concurrent.Futures.immediateCancelledFuture;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import android.content.Context; import android.content.Context;
@ -77,6 +79,7 @@ import androidx.media3.exoplayer.SeekParameters;
import androidx.media3.exoplayer.analytics.AnalyticsListener; import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter;
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer; import androidx.media3.exoplayer.video.MediaCodecVideoRenderer;
import androidx.media3.exoplayer.video.VideoRendererEventListener; import androidx.media3.exoplayer.video.VideoRendererEventListener;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
@ -90,7 +93,6 @@ import java.nio.ByteBuffer;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.checkerframework.checker.initialization.qual.Initialized;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** /**
@ -120,7 +122,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* </ul> * </ul>
*/ */
@UnstableApi @UnstableApi
public final class ExperimentalFrameExtractor implements AnalyticsListener { public final class ExperimentalFrameExtractor {
/** Configuration for the frame extractor. */ /** Configuration for the frame extractor. */
public static final class Configuration { public static final class Configuration {
@ -261,20 +263,15 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
* The last {@link Frame} that was extracted successfully. Accessed on the {@linkplain * The last {@link Frame} that was extracted successfully. Accessed on the {@linkplain
* ExoPlayer#getApplicationLooper() ExoPlayer application thread}. * ExoPlayer#getApplicationLooper() ExoPlayer application thread}.
*/ */
private @MonotonicNonNull Frame lastExtractedFrame; @Nullable private Frame lastExtractedFrame;
/** /**
* Creates an instance. * Creates an instance.
* *
* @param context {@link Context}. * @param context {@link Context}.
* @param configuration The {@link Configuration} for this frame extractor. * @param configuration The {@link Configuration} for this frame extractor.
* @param mediaItem The {@link MediaItem} from which frames are extracted.
* @param effects The {@link List} of {@linkplain Effect video effects} to apply to the extracted
* video frames.
*/ */
// TODO: b/350498258 - Support changing the MediaItem. public ExperimentalFrameExtractor(Context context, Configuration configuration) {
public ExperimentalFrameExtractor(
Context context, Configuration configuration, MediaItem mediaItem, List<Effect> effects) {
player = player =
new ExoPlayer.Builder( new ExoPlayer.Builder(
context, context,
@ -292,29 +289,41 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
}) })
.setSeekParameters(configuration.seekParameters) .setSeekParameters(configuration.seekParameters)
.build(); .build();
player.addAnalyticsListener(new PlayerListener());
playerApplicationThreadHandler = new Handler(player.getApplicationLooper()); playerApplicationThreadHandler = new Handler(player.getApplicationLooper());
extractedFrameNeedsRendering = new AtomicBoolean(); extractedFrameNeedsRendering = new AtomicBoolean();
// TODO: b/350498258 - Extracting the first frame is a workaround for ExoPlayer.setVideoEffects
// returning incorrect timestamps if we seek the player before rendering starts from zero.
frameBeingExtractedCompleterAtomicReference = new AtomicReference<>(null); frameBeingExtractedCompleterAtomicReference = new AtomicReference<>(null);
lastRequestedFrameFuture = immediateCancelledFuture();
}
/**
* Sets a new {@link MediaItem}.
*
* <p>Changing between SDR and HDR {@link MediaItem}s is not supported when {@link
* Configuration#extractHdrFrames} is true.
*
* @param mediaItem The {@link MediaItem} from which frames will be extracted.
* @param effects The {@link List} of {@linkplain Effect video effects} to apply to the extracted
* video frames.
*/
public void setMediaItem(MediaItem mediaItem, List<Effect> effects) {
ListenableFuture<Frame> previousRequestedFrame = lastRequestedFrameFuture;
// TODO: b/350498258 - Extracting the first frame is a workaround for ExoPlayer.setVideoEffects
// returning incorrect timestamps if we seek the player before rendering starts from zero.
lastRequestedFrameFuture = lastRequestedFrameFuture =
CallbackToFutureAdapter.getFuture( CallbackToFutureAdapter.getFuture(
completer -> { completer -> {
frameBeingExtractedCompleterAtomicReference.set(completer); previousRequestedFrame.addListener(
// TODO: b/350498258 - Refactor this and remove declaring this reference as
// initialized to satisfy the nullness checker.
@SuppressWarnings("nullness:assignment")
@Initialized
ExperimentalFrameExtractor thisRef = this;
playerApplicationThreadHandler.post(
() -> { () -> {
player.addAnalyticsListener(thisRef); frameBeingExtractedCompleterAtomicReference.set(completer);
player.setVideoEffects(thisRef.buildVideoEffects(effects)); lastExtractedFrame = null;
player.setVideoEffects(buildVideoEffects(effects));
player.setMediaItem(mediaItem); player.setMediaItem(mediaItem);
player.setPlayWhenReady(false); player.setPlayWhenReady(false);
player.prepare(); player.prepare();
}); },
return "ExperimentalFrameExtractor constructor"; playerApplicationThreadHandler::post);
return "ExperimentalFrameExtractor.setMediaItem";
}); });
} }
@ -369,6 +378,12 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
} }
if (playerError != null) { if (playerError != null) {
completer.setException(playerError); completer.setException(playerError);
} else if (player.getCurrentMediaItem() == null) {
completer.setException(
new PlaybackException(
"Player has no current item. Call setMediaItem before getFrame.",
null,
ERROR_CODE_SETUP_REQUIRED));
} else { } else {
checkState(frameBeingExtractedCompleterAtomicReference.compareAndSet(null, completer)); checkState(frameBeingExtractedCompleterAtomicReference.compareAndSet(null, completer));
extractedFrameNeedsRendering.set(false); extractedFrameNeedsRendering.set(false);
@ -395,35 +410,6 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
waitForRelease.blockUninterruptible(); waitForRelease.blockUninterruptible();
} }
// AnalyticsListener
@Override
public void onPlayerError(EventTime eventTime, PlaybackException error) {
// Fail the next frame to be extracted. Errors will propagate to later pending requests via
// Future callbacks.
@Nullable
CallbackToFutureAdapter.Completer<Frame> frameBeingExtractedCompleter =
frameBeingExtractedCompleterAtomicReference.getAndSet(null);
if (frameBeingExtractedCompleter != null) {
frameBeingExtractedCompleter.setException(error);
}
}
@Override
public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) {
// The player enters STATE_BUFFERING at the start of a seek.
// At the end of a seek, the player enters STATE_READY after the video renderer position has
// been reset, and the renderer reports that it's ready.
if (state == Player.STATE_READY && !extractedFrameNeedsRendering.get()) {
// If the seek resolves to the current position, the renderer position will not be reset
// and extractedFrameNeedsRendering remains false. No frames are rendered. Repeat the
// previously returned frame.
CallbackToFutureAdapter.Completer<Frame> frameBeingExtractedCompleter =
checkNotNull(frameBeingExtractedCompleterAtomicReference.getAndSet(null));
frameBeingExtractedCompleter.set(checkNotNull(lastExtractedFrame));
}
}
@VisibleForTesting @VisibleForTesting
/* package */ ListenableFuture<@NullableType DecoderCounters> getDecoderCounters() { /* package */ ListenableFuture<@NullableType DecoderCounters> getDecoderCounters() {
SettableFuture<@NullableType DecoderCounters> decoderCountersSettableFuture = SettableFuture<@NullableType DecoderCounters> decoderCountersSettableFuture =
@ -447,6 +433,35 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
return listBuilder.build(); return listBuilder.build();
} }
private final class PlayerListener implements AnalyticsListener {
@Override
public void onPlayerError(EventTime eventTime, PlaybackException error) {
// Fail the next frame to be extracted. Errors will propagate to later pending requests via
// Future callbacks.
@Nullable
CallbackToFutureAdapter.Completer<Frame> frameBeingExtractedCompleter =
frameBeingExtractedCompleterAtomicReference.getAndSet(null);
if (frameBeingExtractedCompleter != null) {
frameBeingExtractedCompleter.setException(error);
}
}
@Override
public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) {
// The player enters STATE_BUFFERING at the start of a seek.
// At the end of a seek, the player enters STATE_READY after the video renderer position has
// been reset, and the renderer reports that it's ready.
if (state == Player.STATE_READY && !extractedFrameNeedsRendering.get()) {
// If the seek resolves to the current position, the renderer position will not be reset
// and extractedFrameNeedsRendering remains false. No frames are rendered. Repeat the
// previously returned frame.
CallbackToFutureAdapter.Completer<Frame> frameBeingExtractedCompleter =
checkNotNull(frameBeingExtractedCompleterAtomicReference.getAndSet(null));
frameBeingExtractedCompleter.set(checkNotNull(lastExtractedFrame));
}
}
}
private final class FrameReader implements GlEffect { private final class FrameReader implements GlEffect {
@Override @Override
public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr)
@ -609,7 +624,7 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
private boolean frameRenderedSinceLastPositionReset; private boolean frameRenderedSinceLastPositionReset;
private List<Effect> effectsFromPlayer; private List<Effect> effectsFromPlayer;
private @MonotonicNonNull Effect rotation; @Nullable private Effect rotation;
public FrameExtractorRenderer( public FrameExtractorRenderer(
Context context, Context context,
@ -627,6 +642,18 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
effectsFromPlayer = ImmutableList.of(); effectsFromPlayer = ImmutableList.of();
} }
@Override
protected void onStreamChanged(
Format[] formats,
long startPositionUs,
long offsetUs,
MediaSource.MediaPeriodId mediaPeriodId)
throws ExoPlaybackException {
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
frameRenderedSinceLastPositionReset = false;
setRotation(null);
}
@Override @Override
public void setVideoEffects(List<Effect> effects) { public void setVideoEffects(List<Effect> effects) {
effectsFromPlayer = effects; effectsFromPlayer = effects;
@ -653,17 +680,21 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
// Some decoders do not apply rotation. It's no extra cost to rotate with a GL matrix // Some decoders do not apply rotation. It's no extra cost to rotate with a GL matrix
// transformation effect instead. // transformation effect instead.
// https://developer.android.com/reference/android/media/MediaCodec#transformations-when-rendering-onto-surface // https://developer.android.com/reference/android/media/MediaCodec#transformations-when-rendering-onto-surface
rotation = setRotation(
new ScaleAndRotateTransformation.Builder() new ScaleAndRotateTransformation.Builder()
.setRotationDegrees(360 - format.rotationDegrees) .setRotationDegrees(360 - format.rotationDegrees)
.build(); .build());
setEffectsWithRotation();
formatHolder.format = format.buildUpon().setRotationDegrees(0).build(); formatHolder.format = format.buildUpon().setRotationDegrees(0).build();
} }
} }
return super.onInputFormatChanged(formatHolder); return super.onInputFormatChanged(formatHolder);
} }
private void setRotation(@Nullable Effect rotation) {
this.rotation = rotation;
setEffectsWithRotation();
}
private void setEffectsWithRotation() { private void setEffectsWithRotation() {
ImmutableList.Builder<Effect> effectBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<Effect> effectBuilder = new ImmutableList.Builder<>();
if (rotation != null) { if (rotation != null) {