Skip to content

perf(studio): films with thousands of timed clips stop re-reading every clip each frame - #6805

Merged
miguel-heygen merged 2 commits into
mainfrom
perf/cliprobe-start-once
Oct 30, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
perf/cliprobe-start-once

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

A film with thousands of timed clips (word-by-word captions, many cuts) no longer freezes Studio when you press play: pressing play at 3000 clips used to block the page for about 10 s, and now plays at 30 fps.

Why

Two per-clip costs grew with the square of the clip count or ran on every frame:

  • The runtime resolved every never-styled clip's start on every frame (styleClipsDueVisibleOnce) through a fresh resolver per call, so the resolver's caches never hit. At 3000 clips that is thousands of attribute and ancestor reads per frame.
  • The timeline scan (parseTimelineFromDOM and buildTimelineElementsFromClips) asked for each clip's occurrence index among its siblings without an index pass, so each clip ran its own whole-document querySelectorAll and resolved the source file of every match. The pass that makes this linear already existed for the editor's layer walk; these two entry points never used it.

Related work

#6773 removed the other half of this on the timeline (clip redraws). No open change touches these two paths.

How

  • init.ts: the start of each never-styled clip is cached per timing revision (the revision the seek already reads), and the cheap start comparison runs before any DOM read. The condition is unchanged.
  • timelineDOM.ts and timelineSyncHydration.ts: the scan and the manifest hydration run inside withSelectorIndexPass, one occurrence index per selector for the whole walk.

Before

Pressing play on a generated 3000-clip film: one frame in the 6 s window, the page blocked for 9.8 s.

Before: one 9.8 s frame after pressing play

After

The same film with this change: 256 frames in 6 s, worst frame 117 ms. The two red frames at the start are what is left (the first frames after play, 100 to 117 ms); the rest stay under the 16 ms to 50 ms lines.

After: 256 frames in 6 s, worst 117 ms

Test plan

fails without the fix:

  • reads a far clip's start once while playing, not on every frame in packages/core/src/runtime/init.startsOnce.test.ts: 60 reads (one per frame) without the change, 0 with it.
  • queries the document once for a class many clips share, not once per clip in packages/studio/src/player/hooks/timelineDomBinding.test.ts: 100 queries without the change, at most 1 with it.

What I measured

Generated film of N timed word clips over 120 s, Studio in headless Chrome on a Linux box (page frame rate over 6 s of play, worst frame, sum of long-task time over the window), three runs each, before then after:

Clips fps before fps after worst frame before worst frame after
1000 48.9, 48.7, 48.5 48, 47.5, 42.6 850 to 883 ms 83 to 117 ms
3000 9.4, 25.8, 5.7 33.8, 32.2, 10.6 300 to 850 ms 283 to 567 ms
5000 17.8, 21.1, 19.9 27.2, 30, 29.2 400 to 433 ms 383 to 583 ms

Long-task time fell from 154 to 749 percent of the window to 0 to 53 percent. The studio src/player tests (2431) pass three times in a row; oxfmt and oxlint are clean on the changed files.

What I did NOT execute

  • Desktop's own long-project walk; this is Studio alone.

  • A build of the inspector package on the test box (its copy step exits 1 after the bundle and the Studio files are written, so I could not rule out a stale inspector in the CLI the numbers came from).

  • The 3000-clip runs still vary widely (10.6 to 33.8 fps after): another per-frame cost remains and is not part of this change.

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments"

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 1 nit · head 0407447f

  • blocker — Comments is red on this head: the comment-share ratchet fails for packages/core/src/runtime/init.ts (897→899 comment lines) and packages/studio/src/player/hooks/timelineSyncHydration.ts (92→94). Cut the two new comment lines in each file, or fold the explanation into a name. ↓ inline
Scope

Read all 5 files and checked withSelectorIndexPass's rule: nothing in either pass writes to the DOM, and every getSourceScopedSelectorIndex call inside goes through getTimelineElementSourceFile. The per-revision start cache follows the same revision contract as the seek's other caches. Ran both named tests locally against the merge-base sources: they fail without the fix (expected 60 to be +0, expected 100 to be less than or equal to 1) and pass with it. Studio src/player/lib + timelineDomBinding pass: 307/307. CI and Windows were still running when I posted this.

Comment thread packages/core/src/runtime/init.ts Resolved
timelines?: Readonly<Record<string, TimelineLike>>,
): TimelineElement[] {
// One occurrence index per selector for the whole scan; see buildTimelineElementsFromClips.
return withSelectorIndexPass(doc, () => scanTimelineFromDOM(doc, rootDuration, timelines));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit — the new test only drives buildTimelineElementsFromClips. If this wrap were removed, nothing would fail. A sibling test that counts .w queries through parseTimelineFromDOM on the same 200-span document would pin it.

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 112 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (2)

  • seqplainkeys-none-center-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqab-none-pct-r0-root-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE · 1 resolved, 0 open, 0 new · 0407447 → c80171c. This head only removes comments; CI, regression, Windows and Comments all pass.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit d83f276 Oct 30, 2026
96 checks passed
@miguel-heygen
miguel-heygen deleted the perf/cliprobe-start-once branch October 30, 2026 07:43
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