fix(preview): hold the played frame on pause until the exact stop-frame composite lands#140
Closed
appergb wants to merge 1 commit into
Closed
fix(preview): hold the played frame on pause until the exact stop-frame composite lands#140appergb wants to merge 1 commit into
appergb wants to merge 1 commit into
Conversation
…me composite lands
After timeline playback stops (user pause OR end-of-timeline) the preview
sometimes jumped to an EARLIER frame than the real pause position, then snapped
forward. Root cause is the two-surface switch: on pause the composite <img>
became visible immediately, but useTimelineFrame held the PRE-PLAYBACK composite
(it was disabled during playback and intentionally never clears its dataUrl), and
the true stop frame was only requested ~140ms later (the scrub debounce timer was
reset on every rAF activeFrame write) plus an async ffmpeg/wgpu/PNG round-trip —
so the stale frame showed first, then the correct one. Intermittent because it is
a timing race; sometimes the held frame was already close enough.
Upstream (VideoEngine.swift / PreviewView.swift) has ONE surface: a single
AVPlayerLayer parked on the player's current CMTime, so pause() just freezes the
current frame — the displayed frame always equals the player position, never an
earlier one. The WebView can't run that, so faithfulness means the play→pause
SWITCH between our two surfaces must be frame-consistent.
Fix (minimal, no self-invented opacity/debounce hacks):
- Split the debounce by intent: targetFrame (immediate, rounded/clamped stop
frame) vs scrubFrame (debounced 140ms, paused scrubbing only). On the
play→pause settle, request targetFrame at once — no 140ms delay.
- useTimelineFrame now returns { dataUrl, readyFrame }; readyFrame is the frame
the current dataUrl was composited for, so Preview can gate the surface swap on
"composite == exact stop frame".
- Hold the already-paused <video>'s last decoded frame visible (its frame IS the
true stop frame) through a short "settle" window; only swap to the composite
<img> once readyFrame === targetFrame. A bounded 800ms ceiling falls back to the
<img> path if the composite never resolves (e.g. outside Tauri).
- TimelinePlaybackLayer gains a holdVisible prop driving PAINT only; the clock
still releases and elements still pause on !playing, so no motion/audio resumes.
No pause-black (a correct frame is always painted), no backward flash (swap only
on exact-frame match), no composite storm (debounce retained for scrubbing). The
full faithful endpoint remains the single-surface streaming engine (#53).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
时间线播放后暂停(用户暂停 或 播放到末尾自动停),预览画面有时会先跳回比真实停帧更早的位置,再蹦到正确帧。这说明暂停帧与预览帧不一致。
根因(双渲染面切换不一致)
<video>;暂停时切到 GPU 合成图<img>。<img>立刻可见,但useTimelineFrame在播放期enabled=false且刻意不清空 dataUrl,所以它保留的是**"按下播放前那一帧"**的合成图。composeFrame(140ms 防抖,且播放期每个 rAF 都重置计时器 → 滞后)+ ffmpeg/wgpu/PNG 异步往返后才更新 → 于是"先旧帧,后正确帧"。上游对照
上游
VideoEngine.swift/PreviewView.swift是单一AVPlayerLayer,停在 player 当前 CMTime;pause()只冻结当前帧——显示帧恒等于播放位置,绝不回退。WebView 跑不了 AVFoundation,所以忠实复刻 = 让两面之间的切换帧一致。修复(最小,不引入自创 opacity/防抖 hack)
targetFrame(立即,取整+clamp 的停帧)vsscrubFrame(140ms 防抖,仅暂停态拖拽)。play→pause边沿立即请求 targetFrame,无 140ms 延迟。useTimelineFrame现返回{ dataUrl, readyFrame };readyFrame是当前 dataUrl 对应的帧,供 Preview 用readyFrame === targetFrame作切换闸门。<video>的最后解码帧可见(它就是真实停帧),进入短 "settle" 窗口;只有当readyFrame === targetFrame才切到<img>。800ms 上限兜底:合成图始终不就绪(如非 Tauri 环境)则回退到原<img>路径,绝不卡死。TimelinePlaybackLayer新增holdVisible仅控制绘制;时钟仍按playing释放、元素仍暂停 → 不恢复任何运动/音频。不回归
完整忠实终点仍是单面流式播放引擎 #53;本 PR 是在双面约束下让切换帧一致的最小忠实修复。
测试
tsc -b✅ /vitest run(预览 13 测试)✅ /vite build✅🤖 Generated with Claude Code