From 7ad91a48c18f8655c90c003ec8757e04abb2e6d5 Mon Sep 17 00:00:00 2001 From: tadepall_adobe Date: Thu, 30 Jul 2026 09:49:07 -0700 Subject: [PATCH] Gap at end seek bug --- .../CompositionPreviewViewModel.kt | 52 +++++++++++++ .../composition/ui/CompositionPreviewPane.kt | 6 ++ libraries/effect_ndk/build.gradle.kts | 4 +- .../media3/transformer/CompositionPlayer.java | 78 +++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt index 9ab30c4757a..293207485c5 100644 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt @@ -597,6 +597,51 @@ class CompositionPreviewViewModel(application: Application) : AndroidViewModel(a compositionPlayer.play() } + /** + * Reproduces a CompositionPlayer stuck-seek bug: + * - Requires the composition to end in a GapMediaItem and FrameConsumer to be enabled. + * - Step 1: seekTo(duration-1) — lands the playhead in the trailing gap. If the gap emits + * no frame, Media3's onFrameProcessed never clears waitingForFrameAfterSeek. + * - Step 2: seekTo(0) — silently DEFERRED behind the latched flag. + * - Step 3: play() — no visible playback (position never returns to 0). + * Watch `adb logcat -s SeekDbg RepDbg` while pressing the button. + */ + fun reproduceStuckSeekBug() { + viewModelScope.launch { + val player = compositionPlayer + val duration = player.duration + Log.d("RepDbg", "start: duration=$duration currentPos=${player.currentPosition}") + if (duration <= 0) { + Log.d("RepDbg", "abort: duration<=0. Add media + a trailing Gap before pressing.") + return@launch + } + + // Step 1: land the playhead at duration-1. Position moves 0 → duration-1, a frame + // is emitted, onFrameProcessed clears the flag. + Log.d("RepDbg", "step1: seekTo(${duration - 1}) — expect PROPAGATING + onFrameProcessed clears flag") + player.seekTo(duration - 1) + kotlinx.coroutines.delay(1000) + Log.d("RepDbg", "step1 done: currentPos=${player.currentPosition}") + + // Step 2: redundant seek to the *same* position (mirrors Squirrel's "post-scrub refresh + // seek to "). No-op seek → no frame delivered → onFrameProcessed + // never fires → waitingForFrameAfterSeek stays latched. + Log.d("RepDbg", "step2: seekTo(${duration - 1}) again (no-op) — expect PROPAGATING but NO onFrameProcessed follows") + player.seekTo(duration - 1) + kotlinx.coroutines.delay(1000) + Log.d("RepDbg", "step2 done: currentPos=${player.currentPosition}") + + // Step 3: any follow-up seek is now silently DEFERRED behind the latched flag. + Log.d("RepDbg", "step3: seekTo(0) — expect DEFERRED (flag latched)") + player.seekTo(0) + kotlinx.coroutines.delay(200) + Log.d("RepDbg", "step3 done: currentPos=${player.currentPosition} (should still be at end)") + + Log.d("RepDbg", "step4: play() — expect no visible playback") + player.play() + } + } + fun exportComposition() { // Cancel and clean up files from any ongoing export. cancelExport() @@ -790,9 +835,15 @@ class CompositionPreviewViewModel(application: Application) : AndroidViewModel(a val sequenceBuilder = EditedMediaItemSequence.Builder(trackTypes) + Log.d( + "RepDbg", + "buildSequence[$sequenceIndex] items=${sequenceItems.size} " + + "types=${sequenceItems.joinToString(",") { it.javaClass.simpleName }}", + ) for (item in sequenceItems) { when (item) { is Gap -> { + Log.d("RepDbg", " addGap durationUs=${item.durationUs}") sequenceBuilder.addGap(item.durationUs) } is Media -> { @@ -826,6 +877,7 @@ class CompositionPreviewViewModel(application: Application) : AndroidViewModel(a // Setting duration explicitly is only required for preview with CompositionPlayer, // and is not needed for export with Transformer. .setDurationUs(item.durationUs) + Log.d("RepDbg", " addMedia durationUs=${item.durationUs} uri=${item.uri}") sequenceBuilder.addItem(itemBuilder.build()) } } diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/CompositionPreviewPane.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/CompositionPreviewPane.kt index 338ea46a02e..f26469b57f3 100644 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/CompositionPreviewPane.kt +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/CompositionPreviewPane.kt @@ -238,6 +238,12 @@ internal fun CompositionPreviewPane( Button(onClick = { viewModel.play() }, enabled = uiState.isCompositionSet) { Text(text = stringResource(R.string.play)) } + Button( + onClick = { viewModel.reproduceStuckSeekBug() }, + enabled = uiState.isCompositionSet, + ) { + Text(text = "Reproduce stuck-seek bug") + } Button(onClick = onOpenExportOptions) { Text(text = stringResource(R.string.export_settings)) } diff --git a/libraries/effect_ndk/build.gradle.kts b/libraries/effect_ndk/build.gradle.kts index 49b086e923a..5fb7bf511eb 100644 --- a/libraries/effect_ndk/build.gradle.kts +++ b/libraries/effect_ndk/build.gradle.kts @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -plugins { id("media3.android-library") } +plugins { + id("media3.android-library") +} android { namespace = "androidx.media3.effect.ndk" diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java b/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java index 09f9766cca5..3649ad9fe69 100644 --- a/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java @@ -931,6 +931,10 @@ public void setScrubbingModeEnabled(boolean scrubbingModeEnabled) { : this.playWhenReadyChangeReason); // Bypass debouncing for the final seek when scrubbing ends. + Log.d( + "SeekDbg", + "setScrubbingModeEnabled(false) CLEARING flag; pendingSeekPositionMs=" + + pendingSeekPositionMs); waitingForFrameAfterSeek = false; if (pendingSeekPositionMs != C.TIME_UNSET) { long pendingSeekPosition = pendingSeekPositionMs; @@ -1191,18 +1195,65 @@ private void handleSeekInternal(long positionMs) { "positionMs=%d", positionMs); + Log.d( + "SeekDbg", + "handleSeekInternal enter positionMs=" + + positionMs + + " frameProcessor=" + + (frameProcessor != null) + + " waitingForFrameAfterSeek=" + + waitingForFrameAfterSeek + + " pendingSeekPositionMs=" + + pendingSeekPositionMs + + " aggregatedContentPos=" + + getContentPositionMs()); if (frameProcessor != null) { if (waitingForFrameAfterSeek) { pendingSeekPositionMs = positionMs; + Log.d( + "SeekDbg", + "handleSeekInternal DEFERRED (flag latched) -> pendingSeekPositionMs=" + positionMs); return; } waitingForFrameAfterSeek = true; } + Log.d( + "SeekDbg", "handleSeekInternal PROPAGATING to inner players positionMs=" + positionMs); CompositionPlayerInternal compositionPlayerInternal = checkNotNull(this.compositionPlayerInternal); compositionPlayerInternal.startSeek(positionMs); for (int i = 0; i < playerHolders.size(); i++) { + androidx.media3.exoplayer.ExoPlayer innerPlayer = playerHolders.get(i).player; + Log.d( + "SeekDbg", + " inner[" + + i + + "] PRE-seek state=" + + innerPlayer.getPlaybackState() + + " contentPos=" + + innerPlayer.getContentPosition() + + " duration=" + + innerPlayer.getDuration() + + " playWhenReady=" + + innerPlayer.getPlayWhenReady() + + " mediaItemCount=" + + innerPlayer.getMediaItemCount() + + " currentMediaItemIndex=" + + innerPlayer.getCurrentMediaItemIndex()); playerHolders.get(i).player.seekTo(positionMs); + Log.d( + "SeekDbg", + " inner[" + + i + + "] POST-seek state=" + + innerPlayer.getPlaybackState() + + " contentPos=" + + innerPlayer.getContentPosition() + + " currentMediaItemIndex=" + + innerPlayer.getCurrentMediaItemIndex() + + " (requested " + + positionMs + + ")"); // Flush the HardwareBufferFrameReader and FrameAggregator after the player seeks to ensure // frames from before the seek do not race with the flush calls. if (frameProcessor != null) { @@ -1224,6 +1275,12 @@ private void handleSeekInternal(long positionMs) { } } compositionPlayerInternal.endSeek(); + Log.d( + "SeekDbg", + "handleSeekInternal DONE requestedMs=" + + positionMs + + " aggregatedContentPos=" + + getContentPositionMs()); } @Override @@ -1566,6 +1623,16 @@ private void prepareCompositionPlayerInternal() { } private void setCompositionInternal(Composition composition, long startPositionMs) { + Log.d( + "SeekDbg", + "setCompositionInternal startPositionMs=" + + startPositionMs + + " CLEARING waitingForFrameAfterSeek (was " + + waitingForFrameAfterSeek + + ") pendingSeekPositionMs=(was " + + pendingSeekPositionMs + + ") priorAggregatedPos=" + + getContentPositionMs()); waitingForFrameAfterSeek = false; pendingSeekPositionMs = C.TIME_UNSET; for (int i = 0; i < playerHolders.size(); i++) { @@ -2545,11 +2612,22 @@ public void onVideoSizeChanged(VideoSize videoSize) { public void onFrameProcessed() { applicationHandler.post( () -> { + Log.d( + "SeekDbg", + "onFrameProcessed waitingForFrameAfterSeek=" + + waitingForFrameAfterSeek + + " pendingSeekPositionMs=" + + pendingSeekPositionMs + + " aggregatedContentPos=" + + getContentPositionMs()); if (waitingForFrameAfterSeek) { waitingForFrameAfterSeek = false; if (pendingSeekPositionMs != C.TIME_UNSET) { long pendingSeekPosition = pendingSeekPositionMs; pendingSeekPositionMs = C.TIME_UNSET; + Log.d( + "SeekDbg", + "onFrameProcessed FLUSHING pending seek -> " + pendingSeekPosition); handleSeekInternal(pendingSeekPosition); } }