Skip to content

fix(preview): hold the played frame on pause until the exact stop-frame composite lands#140

Closed
appergb wants to merge 1 commit into
mainfrom
fix/preview-pause-frame-consistency
Closed

fix(preview): hold the played frame on pause until the exact stop-frame composite lands#140
appergb wants to merge 1 commit into
mainfrom
fix/preview-pause-frame-consistency

Conversation

@appergb

@appergb appergb commented Jun 24, 2026

Copy link
Copy Markdown
Owner

问题

时间线播放后暂停(用户暂停 播放到末尾自动停),预览画面有时会先跳回比真实停帧更早的位置,再蹦到正确帧。这说明暂停帧与预览帧不一致。

根因(双渲染面切换不一致)

  • 播放时显示真实 <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 的停帧)vs scrubFrame(140ms 防抖,仅暂停态拖拽)。play→pause 边沿立即请求 targetFrame,无 140ms 延迟。
  • useTimelineFrame 现返回 { dataUrl, readyFrame };readyFrame 是当前 dataUrl 对应的帧,供 Preview 用 readyFrame === targetFrame 作切换闸门。
  • 暂停瞬间保持已暂停 <video> 的最后解码帧可见(它就是真实停帧),进入短 "settle" 窗口;只有当 readyFrame === targetFrame 才切到 <img>。800ms 上限兜底:合成图始终不就绪(如非 Tauri 环境)则回退到原 <img> 路径,绝不卡死。
  • TimelinePlaybackLayer 新增 holdVisible 仅控制绘制;时钟仍按 playing 释放、元素仍暂停 → 不恢复任何运动/音频。

不回归

  • 不黑屏:settle 全程始终有正确帧在画(暂停的 video 帧)。
  • 不回跳:仅在精确同帧匹配时切换,旧合成图永不出现。
  • 不合成风暴:防抖对拖拽原样保留,只有单次 play→pause settle 绕过它取一帧。

完整忠实终点仍是单面流式播放引擎 #53;本 PR 是在双面约束下让切换帧一致的最小忠实修复。

测试

  • tsc -b ✅ / vitest run(预览 13 测试)✅ / vite build
  • 原生 app 真机验证:在装好的 OpenTake 里播放时间线 → 暂停/播放到末尾,确认暂停画面停在真实停帧、无回跳无黑屏(本机 Dock 拦截 computer-use,需人工目视确认)。

🤖 Generated with Claude Code

…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).
@appergb

appergb commented Jun 24, 2026

Copy link
Copy Markdown
Owner Author

关闭:被 #141 取代。#141(集成分支)已包含编辑修复(含 IPC 序列化总根因 #142-A、删除/分割恢复、暂停止血守卫、白底修复),且预览的正式重写已立项 #142(按上游单渲染面 / 流式引擎 #53 重写)。本 PR 的"暂停保持帧"思路已纳入 #142 的重写方案,无需单独维护。

@appergb appergb closed this Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant