Skip to content

fix(timeline): decouple playhead from ancestor re-renders so it tracks playback#120

Merged
EtienneLescot merged 4 commits into
mainfrom
fix/timeline-playhead-lag-111
Jul 19, 2026
Merged

fix(timeline): decouple playhead from ancestor re-renders so it tracks playback#120
EtienneLescot merged 4 commits into
mainfrom
fix/timeline-playhead-lag-111

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #111 — the timeline playhead/scrubber visually lagged behind actual video playback, and dragging it while the video was playing felt laggy/unresponsive.

Root cause

The playhead's position came from currentTime React state, set by VideoEditor (~3300 lines) via setCurrentTime on every requestAnimationFrame tick during playback, and on every seeking event fired while dragging. Each of those calls forced a full re-render of VideoEditorVideoPlayback (~2300 lines) → TimelineEditor (~1800 lines) before the playhead's DOM position actually moved. At 60 ticks/sec (or dozens of seeking events/sec while scrubbing), that render cascade couldn't keep up, so the playhead visibly fell behind the video and dragging felt sluggish.

Playback itself was already driven by requestAnimationFrame reading video.currentTime (not the low-rate timeupdate event), so the fix is specifically about decoupling the playhead's rendering from the rest of the tree, not about the playback clock itself.

Fix

  • PlaybackCursor (in src/components/video-editor/timeline/TimelineEditor.tsx) now reads the video element's current time directly through a new getPlaybackTimeMs getter, in its own requestAnimationFrame loop with its own local useState. Only this small component re-renders per frame — React re-renders start at the component whose state changed, not its ancestors, so VideoEditor's render cost no longer gates how quickly the playhead moves.
  • VideoEditor exposes getPlaybackTimeMs (reading videoPlaybackRef.current.video.currentTime) and threads it down through TimelineEditorTimelinePlaybackCursor.
  • Added src/components/video-editor/videoPlayback/rafCoalescer.ts: coalesces the onTimeUpdatesetCurrentTime state commit (still used by everything else that reads currentTime) to at most once per animation frame, so a burst of seeking events from a fast timeline drag doesn't force one React state commit per event. currentTimeRef (read by other imperative consumers like the Pixi renderer) is still updated synchronously on every call — only the state commit is deferred.

Scoped narrowly to the playhead rendering/update path, per the project's timeline-model-cleanup notes — no changes to the clip in/out/trim/effects region logic.

Verification

  • npx tsc --noEmit: clean (two pre-existing unrelated errors — missing @xenova/transformers/onnxruntime-web type declarations — reproduce identically on main before this change, confirmed via git stash).
  • npm run lint: clean.
  • npm run test: 412/412 passing, including a new rafCoalescer.test.ts covering the coalescing behavior (collapses same-frame calls, schedules a fresh frame for later values, cancel() semantics).
  • Honesty note on desktop verification: I did not drive the real Electron app end-to-end for this change. This worktree's node_modules was empty and its package.json/lockfile has diverged from the main checkout (unrelated agent-tooling dependencies added there), so a full native launch (junctioned bins, HUD, record → editor flow) needed more setup than fit this task's scope. Verification here is unit tests + careful reading of the render/re-render path (confirmed React only re-renders the state-owning component and its descendants, so isolating the tick to PlaybackCursor's own useState genuinely decouples it from VideoEditor/TimelineEditor's render cost). A manual smoke test (play a clip, watch the playhead track it; drag the playhead mid-playback) is recommended before merge.

Test plan

  • npx tsc --noEmit
  • npm run lint
  • npm run test (412 passing)
  • Manual smoke test: record/open a clip, play it, confirm the playhead visually tracks the video without lag; drag the playhead while playing and confirm it feels responsive

Summary by CodeRabbit

  • Bug Fixes
    • Improved timeline playhead accuracy by syncing it to the video’s live playback time, with a fallback when live time isn’t available.
    • Kept timeline time updates smooth by batching playback time events to at most once per animation frame.
    • Improved cleanup during video playback teardown to prevent stray pending updates.
  • Tests
    • Added unit tests for animation-frame coalescing, covering flush timing, cancellation, and rescheduling behavior.

…ks playback

The timeline playhead read its position from `currentTime` React state, set
from VideoEditor (a ~3300-line component) on every rAF tick of video playback
and on every `seeking` event while dragging. Each tick forced a full re-render
of VideoEditor + VideoPlayback + TimelineEditor before the playhead's DOM
moved, so the visual position lagged behind actual video playback and felt
sluggish while scrubbing during playback.

- PlaybackCursor now reads the video element's currentTime directly via a
  `getPlaybackTimeMs` getter, in its own requestAnimationFrame loop and its
  own local `useState`. Only this small component re-renders per frame,
  independent of how long the ancestor tree's render takes.
- Coalesce the `onTimeUpdate` React state commit (still used by everything
  else that reads `currentTime`) to at most once per animation frame via a
  new `createRafCoalescer` helper, so a burst of `seeking` events from a fast
  timeline drag doesn't force one state commit per event.

Fixes #111
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eb7b1d10-2211-4e46-b9bb-d2803760333d

📥 Commits

Reviewing files that changed from the base of the PR and between 27695b9 and ca9c416.

📒 Files selected for processing (1)
  • src/components/video-editor/timeline/TimelineEditor.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/video-editor/timeline/TimelineEditor.tsx

📝 Walkthrough

Walkthrough

Video playback time updates are coalesced with requestAnimationFrame, canceled during cleanup, and exposed to the timeline through a live playback-time callback. The timeline uses this callback for synchronized cursor rendering and drag previews.

Changes

Playback and timeline synchronization

Layer / File(s) Summary
Animation-frame coalescing utility
src/components/video-editor/videoPlayback/rafCoalescer.ts, src/components/video-editor/videoPlayback/rafCoalescer.test.ts
Adds a coalescer that flushes only the latest scheduled value per animation frame, supports cancellation, and tests scheduling and reset behavior.
Video event batching and cleanup
src/components/video-editor/videoPlayback/videoEventHandlers.ts, src/components/video-editor/VideoPlayback.tsx
Routes time updates through the coalescer and invokes disposal during playback effect cleanup.
Live timeline cursor integration
src/components/video-editor/VideoEditor.tsx, src/components/video-editor/timeline/TimelineEditor.tsx
Passes a live video playback-time callback through the timeline components and uses it for cursor rendering and drag previews.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VideoEditor
  participant HTMLVideoElement
  participant TimelineEditor
  participant PlaybackCursor
  VideoEditor->>HTMLVideoElement: Read currentTime
  VideoEditor->>TimelineEditor: Pass getPlaybackTimeMs
  TimelineEditor->>PlaybackCursor: Forward getPlaybackTimeMs
  PlaybackCursor->>HTMLVideoElement: Read live playback position
  PlaybackCursor->>PlaybackCursor: Update playhead on requestAnimationFrame
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: decoupling the timeline playhead from ancestor re-renders so it tracks playback.
Description check ✅ Passed The description covers the summary, root cause, fix, verification, and test plan; only some non-critical template sections are missing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/timeline-playhead-lag-111

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/video-editor/timeline/TimelineEditor.tsx`:
- Around line 318-319: Move the getPlaybackTimeMsRef.current synchronization out
of the render phase and into an effect that runs when getPlaybackTimeMs changes.
Keep the useRef initialization and ensure consumers continue reading the latest
callback after the effect commits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab5ef638-8fa3-4caa-93fd-b209a4363ebc

📥 Commits

Reviewing files that changed from the base of the PR and between d5966ed and 115ab2a.

📒 Files selected for processing (6)
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/components/video-editor/timeline/TimelineEditor.tsx
  • src/components/video-editor/videoPlayback/rafCoalescer.test.ts
  • src/components/video-editor/videoPlayback/rafCoalescer.ts
  • src/components/video-editor/videoPlayback/videoEventHandlers.ts

Comment thread src/components/video-editor/timeline/TimelineEditor.tsx Outdated
Per CodeRabbit review on #120: PlaybackCursor wrote to
getPlaybackTimeMsRef.current directly in the render body instead of in an
effect. Writing to a ref during render is a React anti-pattern — it can leave
the ref holding a stale value from a discarded render pass under concurrent
rendering. Moved the assignment into a useEffect keyed on getPlaybackTimeMs,
matching the recommended pattern for "latest callback" refs.

Verified: tsc --noEmit clean, biome clean, rafCoalescer.test.ts and
zoomRegionUtils.test.ts pass (7/7).
@EtienneLescot
EtienneLescot merged commit 574b685 into main Jul 19, 2026
10 checks passed
@EtienneLescot
EtienneLescot deleted the fix/timeline-playhead-lag-111 branch July 19, 2026 09:02
EtienneLescot added a commit that referenced this pull request Jul 19, 2026
Per CodeRabbit review on #120: PlaybackCursor wrote to
getPlaybackTimeMsRef.current directly in the render body instead of in an
effect. Writing to a ref during render is a React anti-pattern — it can leave
the ref holding a stale value from a discarded render pass under concurrent
rendering. Moved the assignment into a useEffect keyed on getPlaybackTimeMs,
matching the recommended pattern for "latest callback" refs.

Verified: tsc --noEmit clean, biome clean, rafCoalescer.test.ts and
zoomRegionUtils.test.ts pass (7/7).
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.

[Bug]: The edit bar lags and doesn't follow the cursor when playing the video (when dragging it around while playing the video it lags)

1 participant