Skip to content

perf(core): text edits stop rebuilding the preview's caches every frame - #6276

Merged
miguel-heygen merged 2 commits into
mainfrom
perf/text-edits-keep-timing-caches
Oct 25, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
perf/text-edits-keep-timing-caches

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Changing text in a preview no longer makes the runtime throw away and rebuild its timing caches, or rescan the page for color-graded media. Only DOM changes that can move a clip's timing, or add or remove graded media, do.

Why

The runtime watches the document for anything that can change clip windows, durations or composition hosts, and bumps a timing revision when it sees one. Every cache keyed to that revision is rebuilt on the next read: the media clip index, the per-selector query cache, the root-composition cache and the host lookup. The watch listened to child-list changes anywhere in the document (childList + subtree on the root), and any record at all bumped the revision.

The color-grading runtime has its own observer on body with the same shape: any child-list record re-ran refresh(), a querySelectorAll for graded sources plus a pass over every tracked element.

A film whose scenes change text every frame (a typewriter caption setting textContent) therefore rebuilt all of those caches on every frame of playback. On a 50 s 4K film with 7 scenes, 112 short video clips and animated captions, the watch received 408,206 child-list records during one play-through, 245 per frame: 405k text-node swaps inside caption elements and about 1.3 inserted <span>s per frame, none of which carry timing.

Related work

Refs #6189. Independent of it: none of the lines here are inside #6189's changes to init.ts, and the patch applies cleanly on top of it.

How

  • runtime/mutationRecords.ts adds one predicate builder, createRecordFilter(selector, holds?): attribute records count as before (both observers already filter them), and a child-list record counts only when a node it adds or removes matches or contains selector, or holds says it contains something the caller tracks. Text and comment nodes never count.
  • createTimingRecordFilter(timingAttributes) builds the timing selector: an element timing is read from is any element carrying one of the observed timing attributes (which include id, since a start can name another element, and src), video, audio, source, script (a script can register a timeline) or an audio group. Text and comment nodes never count.
  • init.ts builds that predicate from the same attribute list the observer filters on (COMPOSITION_TIMING_ATTRIBUTES), so the observer and the predicate cannot disagree about what a timing input is. The observer callback and every takeRecords() site (queryForTransport, readCompositionTimingRevision, withoutOwnTimingRecords, withoutOwnCompositionRecords) now go through it.
  • colorGrading.ts builds its filter from COLOR_GRADING_SOURCE_SELECTOR, with holds checking the elements it already tracks, so a removed wrapper still drops media graded through setGrading without the attribute.
  • Renders are unchanged in behaviour: the render path already bypasses these caches.

Measured

Studio-like playback of that film in <hyperframes-player> at 1x for the whole 50 s, headless Chrome 152 on an Apple silicon Mac with GPU compositing, GPU rasterization and hardware video decode on. Base is main with #6189 merged on top (the film needs #6189 to play at all); "after" adds this change. Three alternating runs each, trace on; the host was shared (load 7 to 15).

before after
main-thread work per frame, median of runs (p50 / p95) 13.5 / 42.1 ms 11.5 / 40.0 ms
frames with over 8 ms of main-thread work (of ~1,650) 1,337 to 1,373 1,046 to 1,131
main thread busy, ms per s (median) 705 647
script, ms per s (median) 562 498
frame rate (median) 32.8 fps 33.5 fps

With the timing filter alone, one profiled run each with an unminified runtime put the runtime's own time over the 50 s (outside the film's scene code) at 8.2 s before and 6.1 s after (5.0 to 3.7 ms per frame); the color-grading refresh() was 1.3 s of what remained.

In the profile, the four functions behind the rebuilds (queryForTransport, refreshRuntimeMediaCache, mediaStartInput, isCompositionHost, about 2.2 s together) drop out of the top of the list. Frame rate barely moves on this film because its own per-frame scene script is the larger cost; that is outside this repo.

Test plan

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

  • timingMutations.test.ts, on real MutationObserver records: text replaced inside an untimed element and an inserted untimed element do not count; an inserted clip, a subtree holding a <video>, a removed clip, an inserted element with an id, a timing attribute edit and an inserted <script> do.

  • init.timingMutations.test.ts, through the runtime: after a caption's text changes, or a plain <span> is inserted into it, the next seek and frame do not rebuild the media clip index; adding a clip, removing one or changing a clip's data-start does. Fails on main (expected 1 to be +0 on the text case).

  • colorGrading.test.ts: a text edit and an inserted plain <span> do not rescan for graded media (fails with the observer refreshing on every record); graded media inserted inside a wrapper is still adopted and dropped when the wrapper is removed; media graded through setGrading without the attribute is dropped when its wrapper is removed (fails without holds).

  • Each part checked by breaking it: counting every record fails both text cases and the runtime text test; dropping the descendant check fails the subtree case; ignoring removed nodes fails the removed-clip case and the runtime rebuild test.

  • The new and changed test files pass 3 runs in a row. Full packages/core/src/runtime suite: 98 files, 2,422 tests, exit 0. Pre-commit lint, format, typecheck and fallow pass; check-comment-citations and comment-ratchet pass.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 124 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 (1)

  • seqplainkeys-none-px-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

@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 · 0 blocker · 0 important · 0 nits · head d5928892

Scope

Read the four files. Checked every cache keyed to compositionTimingRevision against the new filter: the three queryForTransport selectors (the audio group tag, [data-start], audio[data-start]), the root-composition lookup ([data-composition-id]), the standalone host lookup, the media clip index and the GSAP structure key. Each reads only elements the filter's selector matches, or those elements' ancestry. Changing a timed element's ancestry means moving that element, and the move shows up as a removed and added node, so it still counts. The attribute filter is the same list as before. CI is green on this head.

@miguel-heygen miguel-heygen changed the title perf(core): text edits no longer rebuild the preview's timing caches perf(core): text edits stop rebuilding the preview's caches every frame Oct 25, 2026

@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 · 0 blocker · 0 important · 0 nits · d5928892 → 1c068965

Delta

The timing filter moves to mutationRecords.ts with the same selector. The color-grading watcher still refreshes on a data-hf-color-grading attribute edit, or when an added or removed node is or holds graded media. It also refreshes when a removed node holds something already tracked, so media graded through the API is caught too. Media hidden at refresh is still picked up on redraw through adoptShownMedia, so dropping the text-triggered refreshes does not leave a newly shown video ungraded. CI is green on this head.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 25, 2026
@miguel-heygen
miguel-heygen removed this pull request from the merge queue due to a manual request Oct 25, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 751ca75 Oct 25, 2026
122 of 156 checks passed
@miguel-heygen
miguel-heygen deleted the perf/text-edits-keep-timing-caches branch October 25, 2026 07:33
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