diff --git a/libraries/test_utils/build.gradle b/libraries/test_utils/build.gradle index c47b09606a..cefe2c10c5 100644 --- a/libraries/test_utils/build.gradle +++ b/libraries/test_utils/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation 'com.squareup.okhttp3:mockwebserver:' + okhttpVersion api project(modulePrefix + 'lib-exoplayer') api project(modulePrefix + 'lib-transformer') + testImplementation project(modulePrefix + 'test-utils-robolectric') testImplementation 'androidx.test.espresso:espresso-core:' + androidxTestEspressoVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaPeriod.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaPeriod.java index a8740dd63d..bc8254e3d0 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaPeriod.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaPeriod.java @@ -47,7 +47,9 @@ import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem; import com.google.common.collect.ImmutableList; import com.google.common.collect.Sets; +import com.google.common.math.DoubleMath; import java.io.IOException; +import java.math.RoundingMode; import java.util.Collections; import java.util.List; import java.util.Set; @@ -80,6 +82,33 @@ public class FakeMediaPeriod implements MediaPeriod { ImmutableList.of( oneByteSample(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME), END_OF_STREAM_ITEM); } + + /** + * Creates a {@code TrackDataFactory} which generates samples at the given rate to cover the + * provided duration, with a specified key frame interval. + * + * @param initialSampleTimeUs The time of the initial sample, in microseconds. + * @param sampleRate The number of samples per second. + * @param durationUs The duration of samples to generate, in microseconds. + * @param keyFrameInterval The number of samples between each keyframe (inclusive). + * @return The {@code TrackDataFactory}. + */ + static TrackDataFactory samplesWithRateDurationAndKeyframeInterval( + long initialSampleTimeUs, float sampleRate, long durationUs, int keyFrameInterval) { + return (unusedFormat, unusedMediaPeriodId) -> { + ImmutableList.Builder samples = ImmutableList.builder(); + for (int frameIndex = 0; frameIndex < durationUs / 33_333; frameIndex++) { + long frameTimeUs = + initialSampleTimeUs + + DoubleMath.roundToLong( + (frameIndex * C.MICROS_PER_SECOND) / sampleRate, RoundingMode.DOWN); + samples.add( + FakeSampleStreamItem.oneByteSample( + frameTimeUs, frameIndex % keyFrameInterval == 0 ? C.BUFFER_FLAG_KEY_FRAME : 0)); + } + return samples.add(END_OF_STREAM_ITEM).build(); + }; + } } private final TrackGroupArray trackGroupArray; diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeVideoRenderer.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeVideoRenderer.java index 4490c66329..ff437bda8d 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeVideoRenderer.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeVideoRenderer.java @@ -16,18 +16,20 @@ package androidx.media3.test.utils; +import static androidx.media3.common.util.Assertions.checkNotNull; + import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.VideoSize; -import androidx.media3.common.util.Assertions; import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.DecoderCounters; import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.Renderer; import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.video.VideoFrameMetadataListener; import androidx.media3.exoplayer.video.VideoRendererEventListener; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; @@ -43,6 +45,7 @@ public class FakeVideoRenderer extends FakeRenderer { private final AtomicReference videoSizeRef = new AtomicReference<>(); private @MonotonicNonNull Format format; @Nullable private Object output; + @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; private boolean renderedFirstFrameAfterReset; private boolean mayRenderFirstFrameAfterEnableIfNotStarted; private boolean renderedFirstFrameAfterEnable; @@ -123,7 +126,9 @@ public class FakeVideoRenderer extends FakeRenderer { output = message; renderedFirstFrameAfterReset = false; break; - + case Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER: + this.videoFrameMetadataListener = (VideoFrameMetadataListener) message; + break; case Renderer.MSG_SET_AUDIO_ATTRIBUTES: case Renderer.MSG_SET_AUDIO_SESSION_ID: case Renderer.MSG_SET_AUX_EFFECT_INFO: @@ -131,7 +136,6 @@ public class FakeVideoRenderer extends FakeRenderer { case Renderer.MSG_SET_CHANGE_FRAME_RATE_STRATEGY: case Renderer.MSG_SET_SCALING_MODE: case Renderer.MSG_SET_SKIP_SILENCE_ENABLED: - case Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER: case Renderer.MSG_SET_VOLUME: case Renderer.MSG_SET_WAKEUP_LISTENER: default: @@ -141,32 +145,44 @@ public class FakeVideoRenderer extends FakeRenderer { @Override protected boolean shouldProcessBuffer(long bufferTimeUs, long playbackPositionUs) { - boolean shouldProcess = super.shouldProcessBuffer(bufferTimeUs, playbackPositionUs); boolean shouldRenderFirstFrame = output != null && (!renderedFirstFrameAfterEnable ? (getState() == Renderer.STATE_STARTED || mayRenderFirstFrameAfterEnableIfNotStarted) : !renderedFirstFrameAfterReset); - shouldProcess |= shouldRenderFirstFrame && playbackPositionUs >= getStreamOffsetUs(); + // Process a buffer if it's due within one 60Hz vsync, or it's the first frame. + boolean shouldProcess = + bufferTimeUs < playbackPositionUs + 16_666 + || (shouldRenderFirstFrame && playbackPositionUs >= getStreamOffsetUs()); @Nullable Object output = this.output; - if (shouldProcess && !renderedFirstFrameAfterReset && output != null) { - @MonotonicNonNull Format format = Assertions.checkNotNull(this.format); - handler.post( - () -> { - VideoSize videoSize = - new VideoSize(format.width, format.height, format.pixelWidthHeightRatio); - if (!Objects.equals(videoSize, videoSizeRef.get())) { - eventListener.onVideoSizeChanged(videoSize); - videoSizeRef.set(videoSize); - } - }); - handler.post( - () -> - eventListener.onRenderedFirstFrame( - output, /* renderTimeMs= */ SystemClock.elapsedRealtime())); - renderedFirstFrameAfterReset = true; - renderedFirstFrameAfterEnable = true; + if (shouldProcess && bufferTimeUs >= getLastResetPositionUs()) { + checkNotNull(format); + if (videoFrameMetadataListener != null) { + videoFrameMetadataListener.onVideoFrameAboutToBeRendered( + bufferTimeUs - getStreamOffsetUs(), + /* releaseTimeNs= */ System.nanoTime(), + format, + /* mediaFormat= */ null); + } + if (!renderedFirstFrameAfterReset && output != null) { + Format format = this.format; + handler.post( + () -> { + VideoSize videoSize = + new VideoSize(format.width, format.height, format.pixelWidthHeightRatio); + if (!Objects.equals(videoSize, videoSizeRef.get())) { + eventListener.onVideoSizeChanged(videoSize); + videoSizeRef.set(videoSize); + } + }); + handler.post( + () -> + eventListener.onRenderedFirstFrame( + output, /* renderTimeMs= */ SystemClock.elapsedRealtime())); + renderedFirstFrameAfterReset = true; + renderedFirstFrameAfterEnable = true; + } } return shouldProcess; } diff --git a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeVideoRendererTest.java b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeVideoRendererTest.java new file mode 100644 index 0000000000..c24f458bd1 --- /dev/null +++ b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeVideoRendererTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.media3.test.utils; + +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.advance; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.video.VideoFrameMetadataListener; +import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory; +import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +/** Tests for {@link FakeVideoRenderer}. */ +@RunWith(AndroidJUnit4.class) +public final class FakeVideoRendererTest { + @Test + public void videoFrameMetadataListener_skipsDecodeOnlySamples() throws Exception { + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition.Builder().setWindowPositionInFirstPeriodUs(0).build()); + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + VideoFrameMetadataListener mockVideoFrameMetadataListener = + mock(VideoFrameMetadataListener.class); + player.setVideoFrameMetadataListener(mockVideoFrameMetadataListener); + + player.setMediaSource( + new FakeMediaSource( + timeline, + DrmSessionManager.DRM_UNSUPPORTED, + TrackDataFactory.samplesWithRateDurationAndKeyframeInterval( + /* initialSampleTimeUs= */ 0, + /* sampleRate= */ 30, + /* durationUs= */ DEFAULT_WINDOW_DURATION_US, + /* keyFrameInterval= */ 60), + ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + + advance(player).untilPosition(0, 100); + player.seekTo(2500); + advance(player).untilPosition(0, 2600); + player.stop(); + player.release(); + surface.release(); + + ArgumentCaptor presentationTimeUsCaptor = ArgumentCaptor.forClass(Long.class); + verify(mockVideoFrameMetadataListener, atLeastOnce()) + .onVideoFrameAboutToBeRendered(presentationTimeUsCaptor.capture(), anyLong(), any(), any()); + assertThat(presentationTimeUsCaptor.getAllValues()) + .containsExactly( + 0L, 33_333L, 66_666L, 100_000L, 2_500_000L, 2_533_333L, 2_566_666L, 2_600_000L) + .inOrder(); + } +}