Skip to content

fix(preview): 单时钟播放引擎 + 实时拖拽预览(渲染管线按上游单表面重写,#142)#144

Open
appergb wants to merge 14 commits into
mainfrom
fix/render-pipeline-1to1-upstream
Open

fix(preview): 单时钟播放引擎 + 实时拖拽预览(渲染管线按上游单表面重写,#142)#144
appergb wants to merge 14 commits into
mainfrom
fix/render-pipeline-1to1-upstream

Conversation

@appergb

@appergb appergb commented Jun 24, 2026

Copy link
Copy Markdown
Owner

背景:整套播放/预览架构对照上游重写

用户报告时间轴预览三大问题:暂停抽搐拖动不实时(只在松手才出画面)卡顿。对照上游 palmier-pro(VideoEngine.swift / CompositionBuilder.swift)逐行分析后确认根因是架构层面的"双表面 + 双时钟"拼接,而非局部 bug。

旧实现(双表面 + 双时钟)

  • 播放态:动态挂 DOM <video>/<audio> 栈,音频当 master 时钟,只应用 opacity + 层级
  • 暂停/拖拽态:Rust composite_frame → wgpu → PNG → base64 → <img>,每帧 spawn 一个 ffmpeg 进程,外面套 140ms 尾随防抖
  • 两个 rAF 时钟(usePlaybackTicker + TimelinePlaybackLayer 内部 rAF)靠 mediaClock 引用计数仲裁。

三个症状各自归因:暂停抽搐 = 松手前残留的 rAF tick 把未来帧写进播放头;拖动不实时 = 140ms 防抖;卡顿 = 请求/响应式逐帧合成(ffmpeg spawn + PNG 编码)。

本 PR 做了什么:对齐上游单表面模型

上游是一个 AVPlayer + 一个表面,play/pause/seek/scrub 全走同一表面,只有 .exact / .interactiveScrub 两种 seek 模式。本 PR 把这套忠实映射到浏览器:

状态 表面 对应上游
播放 <video>/<audio> 栈实时播放 AVPlayer 实时播放
拖拽 同一 <video>实时 seek 到帧 .interactiveScrub 松容差
静止 Rust GPU 合成帧(变换/裁剪/文字全保真) .exact 帧精确

改动清单

  • 🆕 previewEngine.ts —— app 级单一时钟引擎 + 共享元素注册表(对齐上游 VideoEngine 是 app 级、PreviewView 只渲染)。一个 rAF 权威时钟,只在 播放||拖拽 时运行,到尾自动暂停。替代 playbackClock 引用计数 + usePlaybackTicker + 组件内 rAF。
  • ✏️ Preview.tsx —— 状态机选面;删掉 140ms 防抖;静止时才取一次合成帧(取已提交的 currentFrame)。
  • ✏️ TimelinePlaybackLayer.tsx —— 降级为纯渲染器(挂载/注册元素、按状态控制可见性,不再持时钟)。
  • ✏️ 接通 isScrubbing(此前定义了但全程没人用):标尺(TimelineContainer scrub 三处,move/trim/marquee/razor 等剪辑手势完全未碰)+ Preview 进度条 pointerdown/up。
  • ✏️ timelinePlayback.ts —— 抽出纯函数 advancePlayhead(可单测)。
  • 🗑️ 删除 playbackClock.ts + usePlaybackTicker.ts
  • 🔖 版本 0.1.0 → 0.1.1。

三个症状如何被消除

  • 暂停抽搐 → 单时钟 + 静止守卫(if(!isPlaying)return 读最新 state),松手前残留 tick 不再写未来帧。
  • 拖动不实时 → 删防抖;拖拽走实时 <video> seek(正是 single-media 预览那条已验证流畅的便宜路径),昂贵合成只在松手后取一次。
  • 卡顿 → 不再每帧 spawn ffmpeg;静止时单次取帧。

+133 / −313 行(删掉双时钟 + 防抖 cruft,合并进一个干净引擎)。

Scope 边界(诚实说明)

测试

  • tsc -b = 0 错误;vitest run = 56 passed(含新增 4 个 advancePlayhead 引擎单测)。
  • 浏览器冒烟(dev server):首页 → 编辑器 → 触发 play,全程零 error/warn,空时间线下引擎被安全即时停止。
  • ⏳ 真实播放/拖拽保真度需原生构建 + 真实素材验证(本地构建中)。

Test plan

  • 原生构建 0.1.1 装入 /Applications,真机点测:拖动标尺画面实时跟手、暂停不抽搐、播放流畅
  • 视频 + 音频混排播放 A/V 同步
  • 单素材预览(MediaPreview)回归无变化

baiqing added 7 commits June 24, 2026 17:30
Rewrite the timeline preview/playback pipeline to upstream's single-surface
model (VideoEngine.swift / PreviewView.swift): the engine owns playback, the
view only renders. Removes the dual-surface / dual-clock stopgap that caused
the reported pause-twitch, non-live scrub, and stutter.

- previewEngine.ts: app-level single clock + shared element registry. One rAF
  authority; runs only while playing or scrubbing; auto-pauses at end. Replaces
  the playbackClock refcount + usePlaybackTicker + the in-component rAF.
- Surface state machine = browser equivalent of upstream exact/interactiveScrub:
  PLAY and SCRUB use the cheap live <video>/<audio> stack (scrub live-seeks to
  the frame); SETTLED uses the high-fidelity Rust GPU composite. Drops the 140ms
  trailing debounce — the expensive composite now fires once, on settle.
- Wire the long-unused isScrubbing through the ruler scrub (TimelineContainer,
  scrub branch only — editing gestures untouched) and the Preview scrub bar.
- TimelinePlaybackLayer becomes a pure renderer (registers elements, no clock).
- advancePlayhead extracted as a pure, unit-tested helper.

Scope: full transform/crop/text compositing DURING playback still awaits the
streaming engine (#53); this lands the faithful single-clock structure and
fixes the three reported playback bugs. Rust composite_frame unchanged.

Bump version 0.1.0 -> 0.1.1.

tsc clean; 56 web tests pass (+4 engine tests).
…142)

Two preview regressions:

- Pause jumped back to the start. The composite targeted currentFrame, which
  the engine freezes at the play-start frame during playback (it only advances
  activeFrame), so the settled composite fetched frame 0. Target activeFrame —
  the live playhead — so the composite is the frame you paused on. useTimelineFrame
  now also returns which frame its url was rendered for; the composite is shown
  only once it has decoded the CURRENT frame, and the <video> backdrop (frozen on
  the pause frame) holds the picture until then — so pausing is correct instantly
  with no stale-frame flash.
- Scrub-bar progress fill rendered as a tall cream bar down the preview's left
  edge: its absolute fill/handle escaped the (unpositioned) track. Add
  position:relative to contain them.

Bump 0.1.1 -> 0.1.2. tsc clean; 56 tests pass.
Codex-authored changes addressing the upstream-consistency audit, brought
onto the working branch. Compiles (tsc clean) and all 56 web tests pass.

- Clipboard: copy/cut/paste clips (⌘C/⌘X/⌘V) + clipboardStore + empty toast.
- Clip right-click context menu (ClipContextMenu) + editActions wiring.
- Snap: stronger targets / includePlayhead / tolerance tweaks (snap.ts).
- Clip helpers + types: new fields and edit helpers (clip.ts, types.ts).
- Timeline interactions: drag/hit-test/canvas refinements (TimelineContainer,
  hitTest, timelineCanvas, clipRenderer incl. link-offset badge).
- uiStore: transient toast; media-folder setter. HoverButton cursor: pointer.
- Preview: position:relative on the stage (contains the scrub-bar fill, audit #10).

Authored by Codex; reviewed for compile + tests only. Per-item correctness vs
upstream is tracked in the audit issues filed alongside.
The transport play/pause button retained keyboard focus after a mouse click.
On WebKit (Tauri WebView) a focused <button>'s Space activation fires on keyup,
which the window keydown handler's preventDefault does not cancel — so Space
toggled play TWICE (focused-button onClick + global shortcut), leaving isPlaying
net-unchanged (the button never flipped to the paused state) and churning the
engine (spurious playhead jump). onMouseDown preventDefault stops the button
taking focus on click; Space is then handled solely by the global shortcut.
onClick is unaffected; Tab focus (accessibility) is preserved.
baiqing added 7 commits June 24, 2026 18:55
ROOT CAUSE (thanks to the Codex audit): the paused-state GPU composite <img>
(position:absolute; inset:0) had no pointerEvents:none, so it intercepted every
mouse event over the preview area — the play/pause button "couldn't be pressed"
and looked stuck (the icon WAS updating; the overlay just blocked the click and
covered it). The state machine was never wrong (verified: togglePlay toggles
isPlaying cleanly).

- Composite <img>: pointerEvents:none.
- <TimelinePlayback> surface: pointerEvents:none (same class of display-only layer).
- Revert the earlier focus-prevention guess on HoverButton (wrong hypothesis;
  togglePlay was confirmed single-call/correct).

The togglePlay rewind-from-end (uiStore) is intended replay behaviour and only
fires at the timeline end; the perceived "jump to start" was a downstream effect
of not being able to pause mid-timeline, resolved by the click fix.
Pause twitch + late-icon (#149, two-agent root-cause): the <video> backdrop
freezes on its real-time decode frame F_video, but the engine's activeFrame lags
by a tick, so the settled GPU composite was fetched for an EARLIER frame and,
when it painted over the frozen video, the picture twitched back. The icon felt
late because that twitch render was the one the user noticed.

Fix: on the pause transition, snap activeFrame to the visual element's frozen
frame (frameForSourceTime of its currentTime), so composeFrame == F_video and the
composite paints the same picture — no jump, pause reads instant.

Also adds docs/EDITING-ENGINE-PLAN.md: the editing-engine implementation map
(ops layer is 1:1 with upstream; gaps are in the wiring layer), the linked-audio
behaviour (upstream design — A1/A2 for video+audio is correct; "no-audio video"
is a stale-cache or upstream-1:1-vs-deviation question for the user), and the
A→B→C close-out plan over issues #145/#146/#147/#86/#87/#98.

No probe.rs change: its channels-missing default is intentional (audio-only
files report no channels in the test mock; flipping would drop real audio).
@appergb

appergb commented Jun 24, 2026

Copy link
Copy Markdown
Owner Author

现状交接(诚实说明,勿误判为「渲染已修好」)

本 PR 已包含:单时钟播放引擎重写、状态机选面 + 实时拖拽、删 140ms 防抖、剪辑引擎工作整合(Codex,审计项)、播放层 pointerEvents:none(#149)、暂停吸附(#149)、版本 0.1.1→0.1.5。剪辑算法核(opentake-ops)已 1:1 上游。

但渲染/播放引擎在双表面模型上仍未根治 —— 用户实测 0.1.5 仍有:中途卡死、渲染无效、暂停抽搐。根因是「双表面 + 双时钟」与上游单表面 AVPlayer 本质不兼容,详见 #151(P0,含架构分析)。

建议: 本 PR 的增量修复可作为脚手架参考合入(剪辑接线/单时钟/实时拖拽都是净改进),但渲染保真 + 不卡死 + 不抽搐必须靠 #53 流式引擎重写,不要再在双表面上叠补丁。详见 docs/EDITING-ENGINE-PLAN.md

关联:#142 #92 #100 #53 #149 #151

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