Fix Frame Extractor getting stuck

Frame Extractor was getting stuck with SeekParameters.CLOSEST_SYNC.
onPositionDiscontinuity callback was sometimes being called with a
non-adjusted new position.

Fix this by monitoring player state ready.
For the player to become ready, we have to override renderer isReady.

PiperOrigin-RevId: 701924752
This commit is contained in:
dancho 2024-12-02 04:53:18 -08:00 committed by Copybara-Service
parent da4376d48d
commit d214e90ce4
2 changed files with 79 additions and 16 deletions

View File

@ -209,6 +209,45 @@ public class FrameExtractorTest {
.isEqualTo(3); .isEqualTo(3);
} }
@Test
public void extractFrame_repeatedPositionMsAndClosestSync_returnsTheSameFrame() throws Exception {
frameExtractor =
new ExperimentalFrameExtractor(
context,
new ExperimentalFrameExtractor.Configuration.Builder()
.setSeekParameters(CLOSEST_SYNC)
.build(),
MediaItem.fromUri(FILE_PATH),
/* effects= */ ImmutableList.of());
ImmutableList<Long> requestedFramePositionsMs = ImmutableList.of(0L, 0L, 33L, 34L, 34L);
ImmutableList<Long> expectedFramePositionsMs = ImmutableList.of(0L, 0L, 0L, 0L, 0L);
List<ListenableFuture<Frame>> frameFutures = new ArrayList<>();
for (long positionMs : requestedFramePositionsMs) {
frameFutures.add(frameExtractor.getFrame(positionMs));
}
for (int i = 0; i < expectedFramePositionsMs.size(); i++) {
ListenableFuture<Frame> frameListenableFuture = frameFutures.get(i);
Frame frame = frameListenableFuture.get(TIMEOUT_SECONDS, SECONDS);
maybeSaveTestBitmap(testId, /* bitmapLabel= */ "actual_" + i, frame.bitmap, /* path= */ null);
Bitmap expectedBitmap =
readBitmap(
/* assetString= */ GOLDEN_ASSET_FOLDER_PATH
+ "sample_with_increasing_timestamps_360p_"
+ String.format(Locale.US, "%.3f", frame.presentationTimeMs / 1000f)
+ ".png");
assertBitmapsAreSimilar(expectedBitmap, frame.bitmap, PSNR_THRESHOLD);
assertThat(frame.presentationTimeMs).isEqualTo(expectedFramePositionsMs.get(i));
}
assertThat(
frameExtractor
.getDecoderCounters()
.get(TIMEOUT_SECONDS, SECONDS)
.renderedOutputBufferCount)
.isEqualTo(1);
}
@Test @Test
public void extractFrame_randomAccess_returnsCorrectFrames() throws Exception { public void extractFrame_randomAccess_returnsCorrectFrames() throws Exception {
frameExtractor = frameExtractor =

View File

@ -24,7 +24,6 @@ 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.Player.DISCONTINUITY_REASON_SEEK;
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;
@ -85,6 +84,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.List; import java.util.List;
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.initialization.qual.Initialized;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@ -211,6 +211,14 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
private final ExoPlayer player; private final ExoPlayer player;
private final Handler playerApplicationThreadHandler; private final Handler playerApplicationThreadHandler;
/**
* An {@link AtomicBoolean} that indicates whether the frame being extracted requires decoding and
* rendering, or if the new seek position resolves to the last extracted frame. Accessed on both
* the {@linkplain ExoPlayer#getApplicationLooper() ExoPlayer application thread}, and the
* ExoPlayer playback thread.
*/
private final AtomicBoolean extractedFrameNeedsRendering;
/** /**
* A {@link SettableFuture} representing the frame currently being extracted. Accessed on both the * A {@link SettableFuture} representing the frame currently being extracted. Accessed on both the
* {@linkplain ExoPlayer#getApplicationLooper() ExoPlayer application thread}, and the video * {@linkplain ExoPlayer#getApplicationLooper() ExoPlayer application thread}, and the video
@ -261,6 +269,7 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
.setSeekParameters(configuration.seekParameters) .setSeekParameters(configuration.seekParameters)
.build(); .build();
playerApplicationThreadHandler = new Handler(player.getApplicationLooper()); playerApplicationThreadHandler = new Handler(player.getApplicationLooper());
extractedFrameNeedsRendering = new AtomicBoolean();
lastRequestedFrameFuture = SettableFuture.create(); lastRequestedFrameFuture = SettableFuture.create();
// TODO: b/350498258 - Extracting the first frame is a workaround for ExoPlayer.setVideoEffects // 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. // returning incorrect timestamps if we seek the player before rendering starts from zero.
@ -314,6 +323,7 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
checkState( checkState(
frameBeingExtractedFutureAtomicReference.compareAndSet( frameBeingExtractedFutureAtomicReference.compareAndSet(
null, frameSettableFuture)); null, frameSettableFuture));
extractedFrameNeedsRendering.set(false);
player.seekTo(positionMs); player.seekTo(positionMs);
} }
}); });
@ -363,14 +373,14 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
} }
@Override @Override
public void onPositionDiscontinuity( public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) {
EventTime eventTime, // The player enters STATE_BUFFERING at the start of a seek.
Player.PositionInfo oldPosition, // At the end of a seek, the player enters STATE_READY after the video renderer position has
Player.PositionInfo newPosition, // been reset, and the renderer reports that it's ready.
@Player.DiscontinuityReason int reason) { if (state == Player.STATE_READY && !extractedFrameNeedsRendering.get()) {
if (oldPosition.equals(newPosition) && reason == DISCONTINUITY_REASON_SEEK) { // If the seek resolves to the current position, the renderer position will not be reset
// When the new seeking position resolves to the old position, no frames are rendered. // and extractedFrameNeedsRendering remains false. No frames are rendered. Repeat the
// Repeat the previously returned frame. // previously returned frame.
SettableFuture<Frame> frameBeingExtractedFuture = SettableFuture<Frame> frameBeingExtractedFuture =
checkNotNull(frameBeingExtractedFutureAtomicReference.getAndSet(null)); checkNotNull(frameBeingExtractedFutureAtomicReference.getAndSet(null));
frameBeingExtractedFuture.set(checkNotNull(lastExtractedFrame)); frameBeingExtractedFuture.set(checkNotNull(lastExtractedFrame));
@ -557,10 +567,10 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
} }
/** A custom MediaCodecVideoRenderer that renders only one frame per position reset. */ /** A custom MediaCodecVideoRenderer that renders only one frame per position reset. */
private static final class FrameExtractorRenderer extends MediaCodecVideoRenderer { private final class FrameExtractorRenderer extends MediaCodecVideoRenderer {
private final boolean toneMapHdrToSdr; private final boolean toneMapHdrToSdr;
private boolean frameRenderedSinceLastReset; private boolean frameRenderedSinceLastPositionReset;
private List<Effect> effectsFromPlayer; private List<Effect> effectsFromPlayer;
private @MonotonicNonNull Effect rotation; private @MonotonicNonNull Effect rotation;
@ -626,9 +636,22 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
super.setVideoEffects(effectBuilder.build()); super.setVideoEffects(effectBuilder.build());
} }
@Override
public boolean isReady() {
// When using FrameReadingGlShaderProgram, frames will not be rendered to the output surface,
// and VideoFrameRenderControl.onFrameAvailableForRendering will not be called. The base class
// never becomes ready.
if (frameRenderedSinceLastPositionReset) {
// Treat this renderer as ready if a frame has been rendered into the effects pipeline.
// The renderer needs to become ready for ExoPlayer to enter STATE_READY.
return true;
}
return super.isReady();
}
@Override @Override
public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
if (!frameRenderedSinceLastReset) { if (!frameRenderedSinceLastPositionReset) {
super.render(positionUs, elapsedRealtimeUs); super.render(positionUs, elapsedRealtimeUs);
} }
} }
@ -647,7 +670,7 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
boolean isLastBuffer, boolean isLastBuffer,
Format format) Format format)
throws ExoPlaybackException { throws ExoPlaybackException {
if (frameRenderedSinceLastReset) { if (frameRenderedSinceLastPositionReset) {
return false; return false;
} }
return super.processOutputBuffer( return super.processOutputBuffer(
@ -667,17 +690,18 @@ public final class ExperimentalFrameExtractor implements AnalyticsListener {
@Override @Override
protected void renderOutputBufferV21( protected void renderOutputBufferV21(
MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) { MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) {
if (frameRenderedSinceLastReset) { if (frameRenderedSinceLastPositionReset) {
// Do not skip this buffer to prevent the decoder from making more progress. // Do not skip this buffer to prevent the decoder from making more progress.
return; return;
} }
frameRenderedSinceLastReset = true; frameRenderedSinceLastPositionReset = true;
super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs); super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs);
} }
@Override @Override
protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException {
frameRenderedSinceLastReset = false; frameRenderedSinceLastPositionReset = false;
extractedFrameNeedsRendering.set(true);
super.onPositionReset(positionUs, joining); super.onPositionReset(positionUs, joining);
} }
} }