Support multiple streams in the ImageRenderer

PiperOrigin-RevId: 565410924
This commit is contained in:
tofunmi 2023-09-14 10:35:12 -07:00 committed by Copybara-Service
parent 5ef5d46708
commit 16b0ea850f
20 changed files with 526 additions and 108 deletions

View file

@ -31,4 +31,7 @@ public interface ImageOutput {
* @param bitmap The new image available. * @param bitmap The new image available.
*/ */
void onImageAvailable(long presentationTimeUs, Bitmap bitmap); void onImageAvailable(long presentationTimeUs, Bitmap bitmap);
/** Called when the renderer is disabled. */
void onDisabled();
} }

View file

@ -15,16 +15,22 @@
*/ */
package androidx.media3.exoplayer.image; package androidx.media3.exoplayer.image;
import static androidx.media3.common.PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK; import static androidx.media3.common.C.FIRST_FRAME_NOT_RENDERED;
import static androidx.media3.common.C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED;
import static androidx.media3.common.C.FIRST_FRAME_RENDERED;
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.Assertions.checkStateNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT; import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT;
import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import androidx.annotation.IntDef;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.PlaybackException; import androidx.media3.common.PlaybackException;
import androidx.media3.common.util.LongArrayQueue;
import androidx.media3.common.util.TraceUtil; import androidx.media3.common.util.TraceUtil;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.decoder.DecoderInputBuffer;
@ -35,31 +41,61 @@ import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities; import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.SampleStream; import androidx.media3.exoplayer.source.SampleStream;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.checker.nullness.qual.RequiresNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull;
// TODO(b/289989736): Currently works for one stream only. Refactor so that it works for multiple
// inputs streams.
/** A {@link Renderer} implementation for images. */ /** A {@link Renderer} implementation for images. */
@UnstableApi @UnstableApi
public final class ImageRenderer extends BaseRenderer { public final class ImageRenderer extends BaseRenderer {
private static final String TAG = "ImageRenderer"; private static final String TAG = "ImageRenderer";
private final DecoderInputBuffer flagsOnlyBuffer; /** Decoder reinitialization states. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({
REINITIALIZATION_STATE_NONE,
REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM_THEN_WAIT,
REINITIALIZATION_STATE_WAIT_END_OF_STREAM
})
private @interface ReinitializationState {}
/** The decoder does not need to be re-initialized. */
private static final int REINITIALIZATION_STATE_NONE = 0;
/**
* The input format has changed in a way that requires the decoder to be re-initialized, but we
* haven't yet signaled an end of stream to the existing decoder. We need to do so in order to
* ensure that it outputs any remaining buffers before we release it.
*/
private static final int REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM_THEN_WAIT = 2;
/**
* The input format has changed in a way that requires the decoder to be re-initialized, and we've
* signaled an end of stream to the existing decoder. We're waiting for the decoder to output an
* end of stream signal to indicate that it has output any remaining buffers before we release it.
*/
private static final int REINITIALIZATION_STATE_WAIT_END_OF_STREAM = 3;
private final ImageDecoder.Factory decoderFactory; private final ImageDecoder.Factory decoderFactory;
private final ImageOutput imageOutput; private final ImageOutput imageOutput;
private final DecoderInputBuffer flagsOnlyBuffer;
private final LongArrayQueue offsetQueue;
private @C.FirstFrameState int firstFrameState;
private boolean inputStreamEnded; private boolean inputStreamEnded;
private boolean outputStreamEnded; private boolean outputStreamEnded;
private long durationUs; private @ReinitializationState int decoderReinitializationState;
private long offsetUs; private @C.FirstFrameState int firstFrameState;
private @Nullable Format inputFormat;
private @Nullable ImageDecoder decoder; private @Nullable ImageDecoder decoder;
private @Nullable DecoderInputBuffer inputBuffer; private @Nullable DecoderInputBuffer inputBuffer;
private @Nullable ImageOutputBuffer outputBuffer; private @Nullable ImageOutputBuffer outputBuffer;
private @MonotonicNonNull Format inputFormat;
/** /**
* Creates an instance. * Creates an instance.
@ -71,11 +107,12 @@ public final class ImageRenderer extends BaseRenderer {
*/ */
public ImageRenderer(ImageDecoder.Factory decoderFactory, ImageOutput imageOutput) { public ImageRenderer(ImageDecoder.Factory decoderFactory, ImageOutput imageOutput) {
super(C.TRACK_TYPE_IMAGE); super(C.TRACK_TYPE_IMAGE);
flagsOnlyBuffer = DecoderInputBuffer.newNoDataInstance();
this.decoderFactory = decoderFactory; this.decoderFactory = decoderFactory;
this.imageOutput = imageOutput; this.imageOutput = imageOutput;
durationUs = C.TIME_UNSET; flagsOnlyBuffer = DecoderInputBuffer.newNoDataInstance();
firstFrameState = C.FIRST_FRAME_NOT_RENDERED; offsetQueue = new LongArrayQueue();
decoderReinitializationState = REINITIALIZATION_STATE_NONE;
firstFrameState = FIRST_FRAME_NOT_RENDERED;
} }
@Override @Override
@ -90,11 +127,11 @@ public final class ImageRenderer extends BaseRenderer {
@Override @Override
public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
checkState(durationUs != C.TIME_UNSET);
if (outputStreamEnded) { if (outputStreamEnded) {
return; return;
} }
// If the offsetQueue is empty, we haven't been given a stream to render.
checkState(!offsetQueue.isEmpty());
if (inputFormat == null) { if (inputFormat == null) {
// We don't have a format yet, so try and read one. // We don't have a format yet, so try and read one.
FormatHolder formatHolder = getFormatHolder(); FormatHolder formatHolder = getFormatHolder();
@ -102,9 +139,9 @@ public final class ImageRenderer extends BaseRenderer {
@SampleStream.ReadDataResult @SampleStream.ReadDataResult
int result = readSource(formatHolder, flagsOnlyBuffer, FLAG_REQUIRE_FORMAT); int result = readSource(formatHolder, flagsOnlyBuffer, FLAG_REQUIRE_FORMAT);
if (result == C.RESULT_FORMAT_READ) { if (result == C.RESULT_FORMAT_READ) {
// Note that this works because we only expect to enter this if-condition once per playback // Note that this works because we only expect to enter this if-condition once per playback.
// for now. inputFormat = checkNotNull(formatHolder.format);
maybeInitDecoder(checkNotNull(formatHolder.format)); initDecoder();
} else if (result == C.RESULT_BUFFER_READ) { } else if (result == C.RESULT_BUFFER_READ) {
// End of stream read having not read a format. // End of stream read having not read a format.
checkState(flagsOnlyBuffer.isEndOfStream()); checkState(flagsOnlyBuffer.isEndOfStream());
@ -116,7 +153,6 @@ public final class ImageRenderer extends BaseRenderer {
return; return;
} }
} }
try { try {
// Rendering loop. // Rendering loop.
TraceUtil.beginSection("drainAndFeedDecoder"); TraceUtil.beginSection("drainAndFeedDecoder");
@ -130,7 +166,9 @@ public final class ImageRenderer extends BaseRenderer {
@Override @Override
public boolean isReady() { public boolean isReady() {
return firstFrameState == C.FIRST_FRAME_RENDERED; return firstFrameState == FIRST_FRAME_RENDERED
|| (firstFrameState == FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED
&& outputBuffer != null);
} }
@Override @Override
@ -138,6 +176,14 @@ public final class ImageRenderer extends BaseRenderer {
return outputStreamEnded; return outputStreamEnded;
} }
@Override
protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) {
firstFrameState =
mayRenderStartOfStream
? C.FIRST_FRAME_NOT_RENDERED
: C.FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED;
}
@Override @Override
protected void onStreamChanged( protected void onStreamChanged(
Format[] formats, Format[] formats,
@ -145,36 +191,41 @@ public final class ImageRenderer extends BaseRenderer {
long offsetUs, long offsetUs,
MediaSource.MediaPeriodId mediaPeriodId) MediaSource.MediaPeriodId mediaPeriodId)
throws ExoPlaybackException { throws ExoPlaybackException {
// TODO(b/289989736): when the mediaPeriodId is signalled to the renders, collect and set
// durationUs here.
durationUs = 2 * C.MICROS_PER_SECOND;
this.offsetUs = offsetUs;
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId); super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
offsetQueue.add(offsetUs);
inputStreamEnded = false;
outputStreamEnded = false;
} }
@Override @Override
protected void onPositionReset(long positionUs, boolean joining) { protected void onPositionReset(long positionUs, boolean joining) {
// Since the renderer only supports playing one image from, this is currently a no-op (don't lowerFirstFrameState(FIRST_FRAME_NOT_RENDERED);
// need to consider a new stream because it will be the same as the last one).
} }
@Override @Override
protected void onDisabled() { protected void onDisabled() {
releaseResources(); offsetQueue.clear();
inputFormat = null;
releaseDecoderResources();
imageOutput.onDisabled();
} }
@Override @Override
protected void onReset() { protected void onReset() {
releaseResources(); offsetQueue.clear();
releaseDecoderResources();
lowerFirstFrameState(FIRST_FRAME_NOT_RENDERED);
} }
@Override @Override
protected void onRelease() { protected void onRelease() {
releaseResources(); offsetQueue.clear();
releaseDecoderResources();
} }
/** /**
* Attempts to dequeue an output buffer from the decoder and, if successful, renders it. * Attempts to dequeue an output buffer from the decoder and, if successful and permitted to,
* renders it.
* *
* @param positionUs The player's current position. * @param positionUs The player's current position.
* @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds,
@ -183,7 +234,7 @@ public final class ImageRenderer extends BaseRenderer {
* @throws ImageDecoderException If an error occurs draining the output buffer. * @throws ImageDecoderException If an error occurs draining the output buffer.
*/ */
private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs)
throws ImageDecoderException { throws ImageDecoderException, ExoPlaybackException {
if (outputBuffer == null) { if (outputBuffer == null) {
checkStateNotNull(decoder); checkStateNotNull(decoder);
outputBuffer = decoder.dequeueOutputBuffer(); outputBuffer = decoder.dequeueOutputBuffer();
@ -191,27 +242,45 @@ public final class ImageRenderer extends BaseRenderer {
return false; return false;
} }
} }
if (outputBuffer.isEndOfStream()) { if (firstFrameState == FIRST_FRAME_NOT_RENDERED_ONLY_ALLOWED_IF_STARTED
outputBuffer.release(); && getState() != STATE_STARTED) {
outputBuffer = null;
outputStreamEnded = true;
return false; return false;
} }
if (checkNotNull(outputBuffer).isEndOfStream()) {
offsetQueue.remove();
if (decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) {
// We're waiting to re-initialize the decoder, and have now processed all final buffers.
releaseDecoderResources();
checkStateNotNull(inputFormat);
initDecoder();
} else {
checkNotNull(outputBuffer).release();
outputBuffer = null;
if (offsetQueue.isEmpty()) {
outputStreamEnded = true;
}
}
return false;
}
checkStateNotNull(outputBuffer);
if (!processOutputBuffer(positionUs, elapsedRealtimeUs)) { if (!processOutputBuffer(positionUs, elapsedRealtimeUs)) {
return false; return false;
} }
firstFrameState = FIRST_FRAME_RENDERED;
firstFrameState = C.FIRST_FRAME_RENDERED;
return true; return true;
} }
@RequiresNonNull("outputBuffer")
@SuppressWarnings("unused") // Will be used or removed when the integrated with the videoSink. @SuppressWarnings("unused") // Will be used or removed when the integrated with the videoSink.
@RequiresNonNull("outputBuffer")
private boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs) { private boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs) {
checkStateNotNull( Bitmap outputBitmap =
outputBuffer.bitmap, "Non-EOS buffer came back from the decoder without bitmap."); checkNotNull(
imageOutput.onImageAvailable(positionUs - offsetUs, outputBuffer.bitmap); outputBuffer.bitmap, "Non-EOS buffer came back from the decoder without bitmap.");
if (positionUs < outputBuffer.timeUs) {
// It's too early to render the buffer.
return false;
}
imageOutput.onImageAvailable(outputBuffer.timeUs - offsetQueue.element(), outputBitmap);
checkNotNull(outputBuffer).release(); checkNotNull(outputBuffer).release();
outputBuffer = null; outputBuffer = null;
return true; return true;
@ -220,9 +289,12 @@ public final class ImageRenderer extends BaseRenderer {
/** /**
* @return Whether we can feed more input data to the decoder. * @return Whether we can feed more input data to the decoder.
*/ */
private boolean feedInputBuffer() throws ExoPlaybackException, ImageDecoderException { private boolean feedInputBuffer() throws ImageDecoderException {
FormatHolder formatHolder = getFormatHolder(); FormatHolder formatHolder = getFormatHolder();
if (decoder == null || inputStreamEnded) { if (decoder == null
|| decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM
|| inputStreamEnded) {
// We need to reinitialize the decoder or the input stream has ended.
return false; return false;
} }
if (inputBuffer == null) { if (inputBuffer == null) {
@ -231,6 +303,14 @@ public final class ImageRenderer extends BaseRenderer {
return false; return false;
} }
} }
if (decoderReinitializationState == REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM_THEN_WAIT) {
checkStateNotNull(inputBuffer);
inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
checkNotNull(decoder).queueInputBuffer(inputBuffer);
inputBuffer = null;
decoderReinitializationState = REINITIALIZATION_STATE_WAIT_END_OF_STREAM;
return false;
}
switch (readSource(formatHolder, inputBuffer, /* readFlags= */ 0)) { switch (readSource(formatHolder, inputBuffer, /* readFlags= */ 0)) {
case C.RESULT_NOTHING_READ: case C.RESULT_NOTHING_READ:
return false; return false;
@ -245,26 +325,18 @@ public final class ImageRenderer extends BaseRenderer {
inputBuffer = null; inputBuffer = null;
return true; return true;
case C.RESULT_FORMAT_READ: case C.RESULT_FORMAT_READ:
if (checkNotNull(formatHolder.format).equals(inputFormat)) { inputFormat = checkNotNull(formatHolder.format);
return true; decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM_THEN_WAIT;
} return true;
throw createRendererException(
new UnsupportedOperationException(
"Changing format is not supported in the ImageRenderer."),
formatHolder.format,
ERROR_CODE_FAILED_RUNTIME_CHECK);
default: default:
throw new IllegalStateException(); throw new IllegalStateException();
} }
} }
@RequiresNonNull("inputFormat")
@EnsuresNonNull("decoder") @EnsuresNonNull("decoder")
private void maybeInitDecoder(Format format) throws ExoPlaybackException { private void initDecoder() throws ExoPlaybackException {
if (inputFormat != null && inputFormat.equals(format) && decoder != null) { if (canCreateDecoderForFormat(inputFormat)) {
return;
}
inputFormat = format;
if (canCreateDecoderForFormat(format)) {
if (decoder != null) { if (decoder != null) {
decoder.release(); decoder.release();
} }
@ -272,7 +344,7 @@ public final class ImageRenderer extends BaseRenderer {
} else { } else {
throw createRendererException( throw createRendererException(
new ImageDecoderException("Provided decoder factory can't create decoder for format."), new ImageDecoderException("Provided decoder factory can't create decoder for format."),
format, inputFormat,
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED); PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED);
} }
} }
@ -283,12 +355,17 @@ public final class ImageRenderer extends BaseRenderer {
|| supportsFormat == RendererCapabilities.create(C.FORMAT_EXCEEDS_CAPABILITIES); || supportsFormat == RendererCapabilities.create(C.FORMAT_EXCEEDS_CAPABILITIES);
} }
private void releaseResources() { private void lowerFirstFrameState(@C.FirstFrameState int firstFrameState) {
this.firstFrameState = min(this.firstFrameState, firstFrameState);
}
private void releaseDecoderResources() {
inputBuffer = null; inputBuffer = null;
if (outputBuffer != null) { if (outputBuffer != null) {
outputBuffer.release(); outputBuffer.release();
} }
outputBuffer = null; outputBuffer = null;
decoderReinitializationState = REINITIALIZATION_STATE_NONE;
if (decoder != null) { if (decoder != null) {
decoder.release(); decoder.release();
decoder = null; decoder = null;

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2020 The Android Open Source Project * Copyright (C) 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -30,7 +30,13 @@ import androidx.media3.test.utils.FakeClock;
import androidx.media3.test.utils.robolectric.PlaybackOutput; import androidx.media3.test.utils.robolectric.PlaybackOutput;
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.media3.test.utils.robolectric.TestPlayerRunHelper;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import com.google.common.collect.ImmutableList; import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.ParameterizedRobolectricTestRunner; import org.robolectric.ParameterizedRobolectricTestRunner;
@ -42,17 +48,23 @@ import org.robolectric.annotation.GraphicsMode;
@RunWith(ParameterizedRobolectricTestRunner.class) @RunWith(ParameterizedRobolectricTestRunner.class)
@GraphicsMode(value = NATIVE) @GraphicsMode(value = NATIVE)
public class ImagePlaybackTest { public class ImagePlaybackTest {
@Parameter public Set<String> inputFiles;
@Parameter public String inputFile;
@Parameters(name = "{0}") @Parameters(name = "{0}")
public static ImmutableList<String> mediaSamples() { public static List<Set<String>> mediaSamples() {
// TODO(b/289989736): When extraction for other types of images is implemented, add those image // Robolectric's ShadowNativeBitmapFactory doesn't support decoding HEIF format, so we don't
// types to this list. // test that here.
// Robolectric's NativeShadowBitmapFactory doesn't support decoding HEIF format, so we don't // TODO b/300457060 - Find out why jpegs cause flaky failures in this test and then add jpegs to
// test that format here. // this list if possible.
return ImmutableList.of( return new ArrayList<>(
"png/non-motion-photo-shortened.png", "jpeg/non-motion-photo-shortened.jpg"); Collections2.filter(
Sets.powerSet(
ImmutableSet.of(
"bitmap/input_images/media3test.png",
"bmp/non-motion-photo-shortened-cropped.bmp",
"png/non-motion-photo-shortened.png",
"webp/ic_launcher_round.webp")),
/* predicate= */ input -> !input.isEmpty()));
} }
@Test @Test
@ -64,23 +76,43 @@ public class ImagePlaybackTest {
ExoPlayer player = ExoPlayer player =
new ExoPlayer.Builder(applicationContext, renderersFactory).setClock(clock).build(); new ExoPlayer.Builder(applicationContext, renderersFactory).setClock(clock).build();
PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory);
long durationMs = 5 * C.MILLIS_PER_SECOND; List<String> sortedInputFiles = new ArrayList<>(inputFiles);
player.setMediaItem( Collections.sort(sortedInputFiles);
new MediaItem.Builder() List<MediaItem> mediaItems = new ArrayList<>(inputFiles.size());
.setUri("asset:///media/" + inputFile) long totalDurationMs = 0;
.setImageDurationMs(durationMs) long currentDurationMs = 3 * C.MILLIS_PER_SECOND;
.build()); for (String inputFile : sortedInputFiles) {
mediaItems.add(
new MediaItem.Builder()
.setUri("asset:///media/" + inputFile)
.setImageDurationMs(currentDurationMs)
.build());
totalDurationMs += currentDurationMs;
if (currentDurationMs < 5 * C.MILLIS_PER_SECOND) {
currentDurationMs += C.MILLIS_PER_SECOND;
}
}
player.setMediaItems(mediaItems);
player.prepare(); player.prepare();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY); TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY);
long playerStartedMs = clock.elapsedRealtime(); long playerStartedMs = clock.elapsedRealtime();
player.play(); player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
long playbackDurationMs = clock.elapsedRealtime() - playerStartedMs; long playbackDurationMs = clock.elapsedRealtime() - playerStartedMs;
player.release(); player.release();
assertThat(playbackDurationMs).isEqualTo(totalDurationMs);
assertThat(playbackDurationMs).isEqualTo(durationMs);
DumpFileAsserts.assertOutput( DumpFileAsserts.assertOutput(
applicationContext, playbackOutput, "playbackdumps/" + inputFile + ".dump"); applicationContext,
playbackOutput,
"playbackdumps/image/" + generateName(sortedInputFiles) + ".dump");
}
private static String generateName(List<String> sortedInputFiles) {
StringBuilder name = new StringBuilder();
for (String inputFile : sortedInputFiles) {
name.append(inputFile, inputFile.lastIndexOf("/") + 1, inputFile.length()).append("+");
}
name.setLength(name.length() - 1);
return name.toString();
} }
} }

View file

@ -20,11 +20,12 @@ import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.o
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.util.Pair;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Clock; import androidx.media3.common.util.Clock;
import androidx.media3.common.util.TimedValueQueue; import androidx.media3.common.util.SystemClock;
import androidx.media3.exoplayer.RendererConfiguration; import androidx.media3.exoplayer.RendererConfiguration;
import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionEventListener;
@ -34,34 +35,65 @@ import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.test.utils.FakeSampleStream; import androidx.media3.test.utils.FakeSampleStream;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
import org.junit.After; import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
/** Unit test for {@link ImageRenderer}. */ /** Unit tests for {@link ImageRenderer}. */
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class ImageRendererTest { public class ImageRendererTest {
private static final long DEFAULT_LOOP_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND;
private static final Format FORMAT = private static final String IS_READY_TIMEOUT_MESSAGE =
"Renderer not ready after " + DEFAULT_LOOP_TIMEOUT_MS + " milliseconds.";
private static final String IS_ENDED_TIMEOUT_MESSAGE =
"Renderer not ended after " + DEFAULT_LOOP_TIMEOUT_MS + " milliseconds.";
private static final String HAS_READ_STREAM_TO_END_TIMEOUT_MESSAGE =
"Renderer has not read stream to end after " + DEFAULT_LOOP_TIMEOUT_MS + " milliseconds.";
private static final Format PNG_FORMAT =
new Format.Builder() new Format.Builder()
.setContainerMimeType(MimeTypes.IMAGE_PNG) .setContainerMimeType(MimeTypes.IMAGE_PNG)
.setTileCountVertical(1) .setTileCountVertical(1)
.setTileCountHorizontal(1) .setTileCountHorizontal(1)
.build(); .build();
private static final Format JPEG_FORMAT =
new Format.Builder()
.setContainerMimeType(MimeTypes.IMAGE_JPEG)
.setTileCountVertical(1)
.setTileCountHorizontal(1)
.build();
private final TimedValueQueue<Bitmap> renderedBitmaps = new TimedValueQueue<>(); private final List<Pair<Long, Bitmap>> renderedBitmaps = new ArrayList<>();
private final Bitmap fakeDecodedBitmap = private final Bitmap fakeDecodedBitmap1 =
Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.ARGB_8888); Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.ARGB_8888);
private final Bitmap fakeDecodedBitmap2 =
Bitmap.createBitmap(/* width= */ 2, /* height= */ 2, Bitmap.Config.ARGB_8888);
private ImageRenderer renderer; private ImageRenderer renderer;
private int decodeCallCount;
@Before @Before
public void setUp() throws Exception { public void setUp() throws Exception {
decodeCallCount = 0;
ImageDecoder.Factory fakeDecoderFactory = ImageDecoder.Factory fakeDecoderFactory =
new DefaultImageDecoder.Factory((data, length) -> fakeDecodedBitmap); new DefaultImageDecoder.Factory(
ImageOutput capturingImageOutput = renderedBitmaps::add; (data, length) -> ++decodeCallCount == 1 ? fakeDecodedBitmap1 : fakeDecodedBitmap2);
renderer = new ImageRenderer(fakeDecoderFactory, capturingImageOutput); ImageOutput queuingImageOutput =
new ImageOutput() {
@Override
public void onImageAvailable(long presentationTimeUs, Bitmap bitmap) {
renderedBitmaps.add(Pair.create(presentationTimeUs, bitmap));
}
@Override
public void onDisabled() {
// Do nothing.
}
};
renderer = new ImageRenderer(fakeDecoderFactory, queuingImageOutput);
renderer.init(/* index= */ 0, PlayerId.UNSET, Clock.DEFAULT); renderer.init(/* index= */ 0, PlayerId.UNSET, Clock.DEFAULT);
} }
@ -73,22 +105,12 @@ public class ImageRendererTest {
} }
@Test @Test
public void renderOneStream_rendersToImageOutput() throws Exception { public void renderOneStream_withMayRenderStartOfStream_rendersToImageOutput() throws Exception {
FakeSampleStream fakeSampleStream = FakeSampleStream fakeSampleStream = createSampleStream(/* timeUs= */ 0);
new FakeSampleStream(
new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024),
/* mediaSourceEventDispatcher= */ null,
DrmSessionManager.DRM_UNSUPPORTED,
new DrmSessionEventListener.EventDispatcher(),
FORMAT,
ImmutableList.of(
oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), END_OF_STREAM_ITEM));
fakeSampleStream.writeData(/* startPositionUs= */ 0); fakeSampleStream.writeData(/* startPositionUs= */ 0);
// TODO(b/289989736): When the mediaPeriodId is signalled to the renders set durationUs here and
// assert on it.
renderer.enable( renderer.enable(
RendererConfiguration.DEFAULT, RendererConfiguration.DEFAULT,
new Format[] {FORMAT}, new Format[] {PNG_FORMAT},
fakeSampleStream, fakeSampleStream,
/* positionUs= */ 0, /* positionUs= */ 0,
/* joining= */ false, /* joining= */ false,
@ -98,15 +120,168 @@ public class ImageRendererTest {
new MediaSource.MediaPeriodId(new Object())); new MediaSource.MediaPeriodId(new Object()));
renderer.setCurrentStreamFinal(); renderer.setCurrentStreamFinal();
while (!renderer.isReady()) { StopWatch isReadyStopWatch = new StopWatch(IS_READY_TIMEOUT_MESSAGE);
while (!renderer.isReady() && isReadyStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
} }
assertThat(renderedBitmaps.size()).isEqualTo(1); assertThat(renderedBitmaps).hasSize(1);
assertThat(renderedBitmaps.poll(0)).isSameInstanceAs(fakeDecodedBitmap); assertThat(renderedBitmaps.get(0).first).isEqualTo(0L);
assertThat(renderedBitmaps.get(0).second).isSameInstanceAs(fakeDecodedBitmap1);
}
renderer.render( @Test
/* positionUs= */ C.MICROS_PER_SECOND, /* elapsedRealtimeUs= */ C.MICROS_PER_SECOND); public void renderOneStream_withoutMayRenderStartOfStream_rendersToImageOutput()
assertThat(renderer.isEnded()).isTrue(); throws Exception {
FakeSampleStream fakeSampleStream = createSampleStream(/* timeUs= */ 0);
fakeSampleStream.writeData(/* startPositionUs= */ 0);
renderer.enable(
RendererConfiguration.DEFAULT,
new Format[] {PNG_FORMAT},
fakeSampleStream,
/* positionUs= */ 0,
/* joining= */ false,
/* mayRenderStartOfStream= */ false,
/* startPositionUs= */ 0,
/* offsetUs= */ 0,
new MediaSource.MediaPeriodId(new Object()));
renderer.setCurrentStreamFinal();
StopWatch isReadyStopWatch = new StopWatch(IS_READY_TIMEOUT_MESSAGE);
while (!renderer.isReady() && isReadyStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
assertThat(renderedBitmaps).isEmpty();
renderer.start();
StopWatch isEndedStopWatch = new StopWatch(IS_ENDED_TIMEOUT_MESSAGE);
while (!renderer.isEnded() && isEndedStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
renderer.stop();
assertThat(renderedBitmaps).hasSize(1);
assertThat(renderedBitmaps.get(0).first).isEqualTo(0L);
assertThat(renderedBitmaps.get(0).second).isSameInstanceAs(fakeDecodedBitmap1);
}
@Test
public void renderTwoStreams_sameFormat_rendersToImageOutput() throws Exception {
FakeSampleStream fakeSampleStream1 = createSampleStream(/* timeUs= */ 0);
fakeSampleStream1.writeData(/* startPositionUs= */ 0);
FakeSampleStream fakeSampleStream2 = createSampleStream(/* timeUs= */ 10);
fakeSampleStream2.writeData(/* startPositionUs= */ 10);
renderer.enable(
RendererConfiguration.DEFAULT,
new Format[] {PNG_FORMAT},
fakeSampleStream1,
/* positionUs= */ 0,
/* joining= */ false,
/* mayRenderStartOfStream= */ true,
/* startPositionUs= */ 0,
/* offsetUs= */ 0,
new MediaSource.MediaPeriodId(new Object()));
StopWatch isReadyStopWatch = new StopWatch(IS_READY_TIMEOUT_MESSAGE);
while (!renderer.isReady() && isReadyStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
renderer.start();
StopWatch hasReadStreamToEndStopWatch = new StopWatch(HAS_READ_STREAM_TO_END_TIMEOUT_MESSAGE);
while (!renderer.hasReadStreamToEnd() && hasReadStreamToEndStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
renderer.replaceStream(
new Format[] {PNG_FORMAT},
fakeSampleStream2,
/* startPositionUs= */ 10,
/* offsetUs= */ 0,
new MediaSource.MediaPeriodId(new Object()));
renderer.setCurrentStreamFinal();
StopWatch isEndedStopWatch = new StopWatch(IS_ENDED_TIMEOUT_MESSAGE);
while (!renderer.isEnded() && isEndedStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 10, /* elapsedRealtimeUs= */ 0);
}
renderer.stop();
assertThat(renderedBitmaps).hasSize(2);
assertThat(renderedBitmaps.get(0).first).isEqualTo(0L);
assertThat(renderedBitmaps.get(0).second).isSameInstanceAs(fakeDecodedBitmap1);
assertThat(renderedBitmaps.get(1).first).isEqualTo(10L);
assertThat(renderedBitmaps.get(1).second).isSameInstanceAs(fakeDecodedBitmap2);
}
@Test
public void renderTwoStreams_differentFormat_rendersToImageOutput() throws Exception {
FakeSampleStream fakeSampleStream1 = createSampleStream(/* timeUs= */ 0);
fakeSampleStream1.writeData(/* startPositionUs= */ 0);
FakeSampleStream fakeSampleStream2 = createSampleStream(/* timeUs= */ 10);
fakeSampleStream2.writeData(/* startPositionUs= */ 10);
renderer.enable(
RendererConfiguration.DEFAULT,
new Format[] {PNG_FORMAT},
fakeSampleStream1,
/* positionUs= */ 0,
/* joining= */ false,
/* mayRenderStartOfStream= */ true,
/* startPositionUs= */ 0,
/* offsetUs= */ 0,
new MediaSource.MediaPeriodId(new Object()));
StopWatch isReadyStopWatch = new StopWatch(IS_READY_TIMEOUT_MESSAGE);
while (!renderer.isReady() && isReadyStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
renderer.start();
StopWatch hasReadStreamToEndStopWatch = new StopWatch(HAS_READ_STREAM_TO_END_TIMEOUT_MESSAGE);
while (!renderer.hasReadStreamToEnd() && hasReadStreamToEndStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0);
}
renderer.replaceStream(
new Format[] {JPEG_FORMAT},
fakeSampleStream2,
/* startPositionUs= */ 10,
/* offsetUs= */ 0,
new MediaSource.MediaPeriodId(new Object()));
renderer.setCurrentStreamFinal();
StopWatch isEndedStopWatch = new StopWatch(IS_ENDED_TIMEOUT_MESSAGE);
while (!renderer.isEnded() && isEndedStopWatch.ensureNotExpired()) {
renderer.render(/* positionUs= */ 10, /* elapsedRealtimeUs= */ 0);
}
renderer.stop();
assertThat(renderedBitmaps).hasSize(2);
assertThat(renderedBitmaps.get(0).first).isEqualTo(0L);
assertThat(renderedBitmaps.get(0).second).isSameInstanceAs(fakeDecodedBitmap1);
assertThat(renderedBitmaps.get(1).first).isEqualTo(10L);
assertThat(renderedBitmaps.get(1).second).isSameInstanceAs(fakeDecodedBitmap2);
}
private static FakeSampleStream createSampleStream(long timeUs) {
return new FakeSampleStream(
new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024),
/* mediaSourceEventDispatcher= */ null,
DrmSessionManager.DRM_UNSUPPORTED,
new DrmSessionEventListener.EventDispatcher(),
PNG_FORMAT,
ImmutableList.of(oneByteSample(timeUs, C.BUFFER_FLAG_KEY_FRAME), END_OF_STREAM_ITEM));
}
private static final class StopWatch {
private final long startTimeMs;
private final long timeOutMs;
private final String timeoutMessage;
public StopWatch(String timeoutMessage) {
startTimeMs = SystemClock.DEFAULT.currentTimeMillis();
timeOutMs = DEFAULT_LOOP_TIMEOUT_MS;
this.timeoutMessage = timeoutMessage;
}
public boolean ensureNotExpired() throws TimeoutException {
if (startTimeMs + timeOutMs < SystemClock.DEFAULT.currentTimeMillis()) {
throw new TimeoutException(timeoutMessage);
}
return true;
}
} }
} }

View file

@ -0,0 +1,5 @@
ImageOutput:
rendered image count = 1
image output #1:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,11 @@
ImageOutput:
rendered image count = 3
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #3:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,14 @@
ImageOutput:
rendered image count = 4
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #3:
presentationTimeUs = 0
bitmap hash = 1367007828
image output #4:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,11 @@
ImageOutput:
rendered image count = 3
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #3:
presentationTimeUs = 0
bitmap hash = 1367007828

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = -1851534335

View file

@ -0,0 +1,11 @@
ImageOutput:
rendered image count = 3
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = 1367007828
image output #3:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680
image output #2:
presentationTimeUs = 0
bitmap hash = 1367007828

View file

@ -0,0 +1,5 @@
ImageOutput:
rendered image count = 1
image output #1:
presentationTimeUs = 0
bitmap hash = -389047680

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #2:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,11 @@
ImageOutput:
rendered image count = 3
image output #1:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #2:
presentationTimeUs = 0
bitmap hash = 1367007828
image output #3:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = -1851534335
image output #2:
presentationTimeUs = 0
bitmap hash = 1367007828

View file

@ -0,0 +1,5 @@
ImageOutput:
rendered image count = 1
image output #1:
presentationTimeUs = 0
bitmap hash = -1851534335

View file

@ -0,0 +1,8 @@
ImageOutput:
rendered image count = 2
image output #1:
presentationTimeUs = 0
bitmap hash = 1367007828
image output #2:
presentationTimeUs = 0
bitmap hash = 443865884

View file

@ -0,0 +1,5 @@
ImageOutput:
rendered image count = 1
image output #1:
presentationTimeUs = 0
bitmap hash = 1367007828

View file

@ -57,6 +57,11 @@ public final class CapturingImageOutput implements Dumpable, ImageOutput {
}); });
} }
@Override
public void onDisabled() {
// Do nothing.
}
@Override @Override
public void dump(Dumper dumper) { public void dump(Dumper dumper) {
dumper.startBlock("ImageOutput"); dumper.startBlock("ImageOutput");