Skip to content

perf(core): the preview mounts and draws only the scenes near the playhead - #6189

Open
miguel-heygen wants to merge 48 commits into
mainfrom
dslivers/far-scenes-skip-render
Open

miguel-heygen wants to merge 48 commits into
mainfrom
dslivers/far-scenes-skip-render

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

The Studio preview of a long film now keeps only the scenes near the playhead mounted, skips drawing the near ones it is not showing, and schedules audio just ahead of the playhead. Renders and exports are unchanged: everything here acts only on preview bundles, whose scene hosts carry data-hf-scene.

  1. Far scenes skip rendering. Mounted scenes beyond the playing scene and one neighbour on each side get content-visibility: hidden (runtime/farScenes.ts), with their measured size pinned through contain-intrinsic-size so scripts that measure them read the same box. Only out-of-flow hosts are skipped: layout containment would move the children and siblings of an in-flow host. A scene's size and position come from its last render, reported by a ResizeObserver after layout, so the frame where a scene leaves reads nothing from it. Before, measureLeaving in farScenes.ts called getComputedStyle(host).position, offsetWidth and offsetHeight on the leaving host while the cut had the page's style dirty, which forced a full style pass inside the cut frame.

  2. The first render reveals only what it renders. A first-time seek used to show every hidden clip of the film for one GSAP render. It now shows only the clips holding targets of tweens that render for the first time in the jump (runtime/firstRenderReveal.ts).

  3. Only scenes near the playhead are mounted. At boot, before any scene script runs, each scene that can be swapped is kept as markup and the ones away from the start are replaced by an empty copy of their host that keeps its timing (runtime/sceneWindow.ts). The window follows play, seek and scrub:

    • it holds the playing scene; paused, the next one too; playing, every scene that starts within the next 10 s, so a scene mounts 10 s before its cut and the cut itself does no mount work;
    • a mount runs in three tasks (markup, scene script, settle), so frames draw between them; until it settles, the scene counts as unmounted and the preview keeps drawing the frame it had;
    • a seek is not done (__hfWaitForSeekCompletion) until the scene it lands in is mounted; other scenes mount one per turn after it, so a later seek overtakes them;
    • Studio mounts a scene before it selects or edits an element in it, and builds the timeline rows of an unmounted scene from the markup it mounts with;
    • the timeline keeps every unmounted scene's clips, built from its markup once;
    • an edit to an unmounted scene goes to its next mount; one that would make it unswappable is refused, so Studio reloads.

    A scene parks only when it can run again cleanly: it passes the swap gate, and every media element and nested composition in it has an authored data-duration (otherwise its timeline length would depend on loaded metadata or a timeline that only exists while mounted). The swap gate now also allows a scene to:

    • load a project-local helper script, held to the same rules as inline scripts;
    • wait for document.fonts.ready or document.fonts.load("…") with literal font strings;
    • measure text on a canvas it creates and never names (document.createElement("canvas").getContext("2d") in one expression, with nothing reading .canvas); every other getContext stays refused, so a scene that draws on a canvas stays mounted;
    • call its own one-argument animate(t) method (the browser's Element.animate(keyframes, timing) stays refused);
    • build its timeline by chaining onto gsap.timeline(…) right where it registers it in __timelines[…] (a chain that is never registered stays refused: that rule exists because a swap only stops registered timelines);
    • load GSAP from a file copied into the project, known by its license banner, as it already could from a CDN.

    Everything else the gate refused stays refused. At run time, a scene that also published its timeline under another scene's id is never unmounted, because unmounting would leave a stopped timeline registered there.

  4. Audio follows the window. Clips are scheduled through Web Audio only when they start within 10 s of the playhead, and the tick schedules the rest as it comes near; clips that already ended are skipped. A scene mounting during play joins the mix without stopping or re-seeking what already plays. A source that a play and a seek both ask for is fetched and decoded once. Decoded audio of a scene that leaves the window is released.

  5. A scene can mount on its own and shows once mounted. A helper script that several scenes load now runs once for the film instead of only with the first scene that loaded it, so any scene can mount without it. A host whose stand-in the visibility pass hid with display: none is shown again when it mounts.

  6. A play frame that draws nothing new skips the hidden-clip lookup. The first-render reveal already knows which hidden clips hold a tween drawing for the first time; when there are none, the page-wide [data-hf-clip-hidden] query is skipped. On a synthetic 20,000-element film of plain timed clips (no unmounting), 10 s of play on the software-rendered machine at 8351fe3, 3 runs each: the query ran 600-601 times and cost 0.068-0.079 ms per frame before, 6 times and 0.001 ms per frame after; main-thread script time per frame went from 0.26-0.29 ms to 0.17-0.24 ms.

  7. An unmounted scene is freed. Six references kept the whole tree of every unmounted scene alive: a captured <audio> (Chrome keeps it for the audio context's lifetime, and it held its scene through its parent), a cache of composition hosts, the clip tree while posts are held during play, the player's first root timeline, root timelines the runtime built and replaced, which stayed on GSAP's global timeline, and timelines a scene's script builds after a font wait, which stayed there too. The script's recorder only sees what it starts before returning, so a mount now also records what starts on GSAP's global timeline until fonts settle (the gate allows no other wait), and stopping a scene also stops the timelines its own timelines drive (a tween of a timeline's time, as tweenFromTo makes), drivers first: a driver's revert sets the driven timeline's time, which would put it back on the global timeline.

Measurements

Synthetic 45-scene, 22.5-minute film built in code: 290,868 elements (about 6.4k SVG elements per scene), 736 <audio> clips (466 at the root, 270 in scenes), a GSAP timeline per scene built by a project helper script, and a font-ready wait in each scene. Served as the Studio preview bundle. Base is main at the merge base; each row is the median of 3 fresh headless Chrome 152 runs per variant, alternating variants (the 10-minute play is one run each).

Protocols:

  • Open: play 6 s from the start right after the page is ready; then scrub ahead from 700 s, one seek per frame at 2 s of film per frame for 5 s; then seek to 700 s and 1300 s, each timed until the seek reports done plus two frames.
  • After a seek: the same, but seek to 626 s first and play across the cut at 630 s.
  • Warm: seek to 632 s and wait, scrub from 300 s, then play across the 630 s cut, then the two seeks.
  • Idle: 30 s paused after open.
  • Long play: 10 minutes of play from the start, memory read after a forced garbage collection every minute.

Software-rendered machine (4 CPUs of a shared Linux box, no GPU: SwiftShader; 1-minute load 5-16 during the runs). "Earlier head" is this PR before the window change (4283542); "this PR" is 17a68b1, before the gate and remount changes above.

Metric main earlier head this PR
Elements in the preview / DOM nodes after a full GC 290,868 / 392,560 13,476 / 80,086 13,476 / 80,086
Open to ready 14.2 s 4.4 s 4.3 s
Idle 30 s after open: renderer CPU 3% 0% 0%
Renderer RSS idle after open 869 MB 282 MB 283 MB
Play fps (open / after seek / warm) 2.1 / 2.1 / 58.2 51.2 / 56.0 / 57.0 50.3 / 58.3 / 59.2
Renderer CPU during play (open / after seek / warm) 150% / 153% / 97% 82% / 49% / 49% 81% / 49% / 33%
Worst play frame (open / after seek / warm) 1,867 / 900 / 83 ms 50 / 233 / 200 ms 100 / 50 / 50 ms
Scrub fps (open / after seek / warm) 0.4 / 0.5 / 27.4 16.5 / 17.9 / 17.4 17.7 / 20.5 / 18.5
Worst scrub frame (open / after seek / warm) 2,783 / 2,133 / 200 ms 467 / 417 / 517 ms 250 / 217 / 283 ms
Seek to 700 s (open / after seek / warm) 717 / 40 / 1,868 ms 422 / 401 / 730 ms 290 / 301 / 490 ms
Seek to 1300 s (open / after seek / warm) 3,028 / 3,001 / 2,461 ms 669 / 705 / 682 ms 358 / 297 / 192 ms
Renderer RSS after the open protocol 1,645 MB 634 MB 589 MB
10 min play: fps, renderer CPU 1.3, 153% 57.3, 110% 56.5, 105%
10 min play: worst frame, long tasks 4,533 ms, 860 350 ms, 40 1,250 ms, 46
10 min play: renderer RSS at 1 min and 10 min 1,053 → 1,052 MB 326 → 376 MB 305 → 334 MB
10 min play: DOM nodes after GC at 1 min and 10 min 392,559 → 392,536 101,095 → 102,764 80,888 → 95,504

Against the earlier head, 17a68b1 is better on the after-seek and warm worst play frames, every scrub row, every seek row and memory. It is worse on the open play's worst frame (100 against 50 ms, one long task) and the 10-minute play's worst frame (one 1,250 ms frame in a single run, while the box's load rose from 6.7 to 11). On main the 10-minute play there reached only 39 s of film.

GPU machine (Apple M5 Pro laptop, Chrome's Metal backend), measured on the earlier head 4283542; GPU rows for this head are not measured yet.

Metric main earlier head
Elements in the preview / DOM nodes after a full GC 290,868 / 392,560 13,476 / 80,086
<audio> / <svg> mounted 736 / 45 478 / 2
Open to ready 4.2 s 1.5 s
Idle 30 s after open: layouts, style recalcs, renderer CPU 0, 0, 5% 0, 0, 1%
Renderer RSS idle after open / JS heap 834 MB / 10 MB 241 MB / 4 MB
Play fps, first play after open 2.4 60
Play fps, first play after a seek (across a cut) 3.6 59.2
Play fps, warm 59.8 59.5
Renderer CPU during play (open / after seek / warm) 131% / 133% / 48% 24% / 19% / 19%
Worst play frame (open / after seek / warm) 733 / 650 / 33 ms 17 / 100 / 67 ms
Style recalc per play frame (open / after seek) 425 / 290 ms 0.31 / 0.37 ms
Scrub fps (open / after seek / warm) 2.4 / 2.3 / 30.9 39.2 / 39.3 / 39.3
Worst scrub frame (open / after seek / warm) 967 / 733 / 1,017 ms 167 / 150 / 183 ms
Seek to 700 s (open / after seek / warm) 308 / 279 / 719 ms 160 / 156 / 254 ms
Seek to 1300 s (open / after seek / warm) 937 / 903 / 944 ms 278 / 236 / 248 ms
Renderer RSS after the open protocol 1,686 MB 766 MB
10 min play: fps, renderer CPU 3.3, 138% 59.7, 32%
10 min play: renderer RSS at 1 min and 10 min 1,068 → 1,256 MB 290 → 414 MB
10 min play: DOM nodes after GC at 1 min and 10 min 392,538 → 392,255 101,095 → 102,762

Real films

Two real projects, served as the Studio preview bundle from a copy, on the software-rendered machine above. Base is current main (683e9b1); this PR is its head (1da8643). Each row is the median of 3 fresh headless Chrome 152 runs per variant, alternating variants; the 5-minute play is one run each. The machine was shared and loaded during these runs; each table gives the 1-minute load range.

A 23-minute film: 45 scenes, 289,512 elements, 573 <audio> clips, GSAP from a CDN. Main refuses all 45 scenes; this PR refuses none and unmounts 43 at open. Load 2.2-12.8.

Metric main this PR
Open to ready (open / after seek / warm runs) 8.6 / 7.2 / 7.2 s 1.1 / 0.68 / 0.69 s
Elements / DOM nodes after a full GC 289,512 / 378,983 7,150 / 91,126
Play fps (open / after seek / warm) 11.9 / 10.4 / 5.8 14.3 / 14.6 / 15.4
Renderer CPU during play 263% / 282% / 247% 228% / 279% / 279%
Worst play frame 250 / 600 / 583 ms 133 / 200 / 183 ms
Scrub fps 4.0 / 6.0 / 8.2 6.1 / 7.2 / 8.6
Worst scrub frame 683 / 500 / 217 ms 317 / 317 / 233 ms
Seek to 700 s 252 / 99 / 255 ms 438 / 550 / 233 ms
Seek to 1300 s 393 / 636 / 475 ms 343 / 330 / 149 ms
Renderer RSS after the protocol 1,240 / 1,187 / 1,385 MB 707 / 794 / 794 MB
5 min play: fps, renderer CPU 8.5, 216% 9.9, 154%
5 min play: worst frame, long tasks 833 ms, 1,354 467 ms, 141
5 min play: renderer RSS at start, 1 min, 5 min 717 → 971 → 1,012 MB 311 → 576 → 585 MB
5 min play: DOM nodes after GC at start and 5 min 378,983 → 378,864 91,126 → 92,917

Play does not reach 30 fps on either side; an earlier 6 s trace of play after a seek put this PR's limit in software rasterization of the film's SVG artwork, not script. Not improved: the seek to 700 s right after open or after a seek, which mounts the scene it lands in (252 → 438 ms, 99 → 550 ms), and the warm worst scrub frame (217 → 233 ms).

A 50-second 3840x2160 film: 7 scenes, each drawing 2D particle canvases from its own GSAP timeline, GSAP from a file in the project. Main and this PR both refuse all 7 scenes ("its script uses getContext"), so none unmounts: what changes here is far scenes skipping rendering and the first render revealing only what it renders. Measured through a 1920x1080 window onto the stage, with the protocol's times scaled to the film's length (scrub from 15.0 s, seeks to 25.4 s and 47.1 s, play across the cut at 23.2 s); the 5-minute play loops the film. Load 3.1-16.9.

Metric main this PR
Open to ready (open / after seek / warm runs) 4.8 / 5.4 / 4.5 s 4.6 / 4.8 / 4.1 s
Elements / DOM nodes after a full GC 20,365 / 39,195 20,366 / 39,197
Play fps (open / after seek / warm) 2.1 / 1.4 / 32.3 12.9 / 32.0 / 32.2
Renderer CPU during play 122% / 137% / 294% 192% / 290% / 292%
Worst play frame 617 / 933 / 350 ms 217 / 417 / 367 ms
Scrub fps 3.3 / 4.3 / 20.2 10.2 / 13.3 / 20.1
Worst scrub frame 983 / 383 / 367 ms 617 / 267 / 417 ms
Seek to 25.4 s 158 / 164 / 345 ms 317 / 526 / 157 ms
Seek to 47.1 s 1,120 / 1,152 / 1,121 ms 598 / 515 / 523 ms
Renderer RSS after the protocol 721 / 777 / 965 MB 764 / 924 / 953 MB
5 min play: fps, renderer CPU 16.8, 209% 23.0, 265%
5 min play: worst frame, long tasks 1,033 ms, 1,648 733 ms, 1,619

Warm play is the same on both (32 fps). Not improved: the seek to 25.4 s right after open or after a seek (158 → 317 ms, 164 → 526 ms), the warm worst play and scrub frames, and renderer memory after the protocol.

Memory over long runs. Each sample follows a forced full garbage collection; both variants ran side by side. The JS heap, DOM nodes and live canvases, media elements and decoded audio buffers stay flat on the 50-second film; the renderer's own memory (raster, decoded images and the 54 canvases' backing stores at 3840x2160) follows what is on screen: in looping play it peaks near the same point of the film each time (about 17-19 s, 1.0-1.3 GB on both in the 5-minute runs) and falls after it, and the seek loops level off after about 10 rounds. So the rise on that film is bounded and the same on main; it is not a leak.

Run main this PR
50-second film, 15 min of looping play: renderer RSS at start, peak, end 386, 1,188, 1,186 MB 387, 1,052, 1,052 MB
same: JS heap / DOM nodes over the run 7-12 MB / 39.1k-40.0k 7-12 MB / 39.2k-40.1k
50-second film, 30 rounds of 24 seeks: renderer RSS after rounds 1, 10, 30 737, 882, 857 MB 735, 878, 887 MB
23-minute film, 30 rounds of 24 seeks: renderer RSS after rounds 1, 10, 30 1,250, 1,285, 1,346 MB 870, 924, 942 MB
same: DOM nodes after rounds 1, 10, 30 378,895, 378,892, 378,892 148,148, 218,240, 213,572

On the 23-minute film this PR's DOM count moves between 131k and 223k from sample to sample with no trend over the 30 rounds: a sample can land while the window still mounts a neighbour. Before the leak fix in item 7, the same loop grew by about 125k nodes and 10 MB of JS heap per round.

Frames after unmounting and mounting again. 36 frames per film, 12 per mode: play past scenes as they unmount, then sample back inside them; seek in, far away and back; scrub across with one seek per frame, then sample inside. Each sample is its own seek, taken once two screenshots in a row agree, with software compositing (as the renderer uses, because SwiftShader's GPU compositor re-presents stale raster after a seek). Main against itself, same sequence twice (683e9b1): 36 of 36 identical on both films.

  • 50-second film, compared over the whole 3840x2160 stage: 36 of 36 identical to main (no scene unmounts there at this head).
  • 23-minute film: 35 of 36 identical. The other, at 735.83 s (49,013 pixels of at most 123 levels), depends on the seeks before it, not on this PR: this PR opened fresh and sought straight there matches main's frame exactly, and main at 4516774 differs from itself between those two paths by the same 49,013 pixels.
  • 680.48 s, which differed at the previous head (40,535 pixels in one character's outlines), is identical at this head. The cause found there: main drew a stale frame. Both pages had the same DOM, computed styles and screen transforms (the character's group rotated 2.8°), but main's painted layer drew the group unrotated; one forced relayout and repaint of that scene made main's frame exactly this PR's (0 pixels), and the same repaint left this PR's unchanged. Main keeps that scene mounted and hidden until the seek shows it. The same 40,535 pixels still separate this PR's fresh-open frame from both sequence frames, so the miss follows the seek history.

Scene cuts on the 50-second film

Each time below is a scene start in this film. The frame that crosses each cut, during play, through the film copy with its own [style*="visibility: hidden"] * rule removed. Same method on both sides: play from about 0.5 s before the cut, the first frame whose film time passes the cut is stamped, and its main-thread task is read from a trace. Software-rendered machine, 3 runs each, alternating, load 2.4-13.0. Main is 89af290; this PR is 7752323 (its runtime is the same at the current head).

Cut main: cut frame (ms, 3 runs) this PR: cut frame (ms, 3 runs)
17.46 s 66, 73, 123 83, 69, 132
23.17 s 101, 102, 111 201, 227, 222
30.5 s 132, 88, 70 205, 208, 178
40.04 s 164, 100, 93 38, 53, 76
45.0 s 93, 108 (one run did not stamp) 116, 178, 136

The forced read named above is gone: against the PR before it (f91ddc7, main 683e9b1 at the time, 3 runs), the forced style pass inside the cut frame went from 32-52 ms to none at 17.46 s, from 145-176 ms to 9 ms at 23.17 s (what remains is the visibility pass's in-flow position read) and from 80-104 ms to none at 30.5 s. The cut frames did not get shorter: the same restyle now runs in the frame's own render. Three cuts are still slower than main; see Known limits.

The single cut frame is a noisy number on both sides: main's measured 88 to 1,466 ms at 23.17 s over 11 runs on one day. Counting the whole second around each cut instead (every main-thread task that ends within 0.5 s of the stamped frame; blocking time is each task's time over 50 ms, summed), main 89af290 against this PR at defa68e, 3 runs each, alternating, load 8-20 on 8 cores, medians:

Cut main: blocking (ms) this PR: blocking (ms) main: longest task (ms) this PR: longest task (ms)
17.46 s 621 139 301 83
23.17 s 638 330 159 208
30.5 s 435 311 158 194
40.04 s 844 284 198 163
45.0 s 377 277 108 101

Around every cut this PR blocks less in total: main runs several tasks of about 100 ms each there, this PR fewer. Its longest single task is still longer than main's at 23.17 s and 30.5 s.

The 23-minute film at this head

Main is 26e3045; this PR is be9a987 (this head's runtime is the same; what came after is a merge of a test-only main commit and comment edits). Software-rendered machine (shared Linux box, 8 cores, SwiftShader), 3 runs each, alternating main and this PR.

Play and scrub. Open: play 6 s from the start right after the page is ready, then scrub ahead from 700 s at 2 s of film per frame for 5 s. After a seek: the same, after a seek to 4 s before the cut at 633.9 s. Warm: seek 2 s past that cut and settle, scrub ahead from 300 s, then play from 4 s before the cut. Load 7.6-17.9.

Metric main (runs) this PR (runs)
Open: play fps 13.2, 13.4, 14.0 15.5, 15.5, 8.1
Open: scrub fps 4.3, 4.8, 4.3 6.5, 6.3, 4.9
After a seek: play fps 4.3, 4.1, 9.4 5.7, 7.9, 13.6
After a seek: scrub fps 3.6, 4.0, 5.8 4.2, 6.3, 7.1
Warm: play fps 5.1, 7.0, 4.5 10.4, 9.8, 12.2
Warm: scrub fps 6.1, 7.3, 6.7 6.9, 6.7, 7.7

The open play run at 8.1 fps ran at the highest load of the six (10.3 at its start, 12.5 at its end).

Frame at the 50-second film's cut times, on the 23-minute film. The main-thread task of the first frame whose film time passes each time, during play from 0.5 s before it. These are not cuts in this film: its only scene cut in this range is at 23.0 s, 0.17 s before the 23.17 s frame, and the other times fall inside scenes. Load 5.5-19.3.

Time main (ms, 3 runs) this PR (ms, 3 runs) Medians, PR minus main
17.6 s 168, 46, 48 80, 72, 71 +24
23.17 s 187, 320, 338 25, 54, 169 -266
30.5 s 118, 106, 48 25, 27, 40 -80
30.7 s 135, 54, 57 12, 18, 19 -40
40 s 46, 29, 28 12, 23, 9 -18
45 s 30, 148, 73 15, 10, 28 -58

At 17.6 s this PR's frames are its ordinary play frames there (52-91 ms each); main's frames nearby include tasks of 170-255 ms.

Package size

This PR adds about 32 KB to @hyperframes/core's built files (its runtime bundle grows by 9 KB, 1.5%, and ships in several formats) and about 8 KB to @hyperframes/producer's packed tarball, which carries that runtime. With main already close to both budgets, the merged head at e2bbe2d measured @hyperframes/core 4,017,248 bytes unpacked, over by more than 1%; that budget is raised to 5% above it, the headroom the size check uses. Main has since lowered @hyperframes/producer's budgets; at be9a987 CI measured it at 782,289 bytes packed and 2,841,105 unpacked, over them by 1.5% and 1.1%, and both are set to those sizes.

Known limits

  • First visits cost a mount. Seeking or scrubbing into a scene that is not mounted runs its script. On the software-rendered machine a mount measured about 165 ms of main-thread work during a scrub: about 15 ms for the markup, 45 ms for the scene's script, and 105 ms to settle (most of it the scene's first style pass, adapter discovery and the timeline post). Main pays that for every scene at open instead (14.2 s above). Two software rows stay worse than main because of it: the warm scrub, which crosses scenes this browser never mounted (27.4 → 18.5 fps), and the seek to 700 s right after scrubbing past it (40 → 301 ms; main had that scene drawn, this PR unmounted it).
  • Scenes the playhead leaves unmount. Keeping recently visited scenes mounted under an element budget was measured and left out: each kept scene makes every later mount slower, because adapter discovery and the timeline post scan the whole document. On the GPU machine, scrub fell from 44 fps with none kept to 30-35 fps with 60k elements kept, and to about 38 with 15k. It only paid off for a revisit within the budget. Mount work that scales with the scene rather than the document would have to come first.
  • The software-rendered open play sits near the bar. It measured 50.3 fps median here; on an earlier, more heavily loaded machine a trace of the same play showed raster workers busy 3.9 s and the display compositor 3.3 s of every 4 s, so the limit there is CPU rasterization, not script.
  • Captured audio elements stay alive. Chrome keeps an element captured by Web Audio for the audio context's lifetime. Each scene that mounts again adds its captured clips (about 135 DOM nodes each, with their media internals), not its whole tree: about 1.6k nodes per minute of continuous play on this fixture.
  • A scene mount during play is a long task. When a mounted scene settles, the CSS animation adapter reads computed style for every element in the document. On the 50-second film at 8351fe3, when this PR still unmounted its scenes, traces of play on the software-rendered machine showed 0.4-1.5 s per mount with the runtime's then [data-hf-clip-hidden][style*="visibility: hidden"] * rule, and 15-110 ms with that rule removed. perf(core): Safari stops re-serializing inline styles on every GSAP write #5974, merged in from main, replaces that rule with an attribute-only one; the mount cost after it is not measured separately (a 420 ms timer task remained in a trace of play across the cut at e2bbe2d).
  • The longest task at two cuts on the 50-second film is longer than main's (second table above: 23.17 s and 30.5 s), though the total blocking around them is lower. At 23.17 s that task is one style pass over the incoming scene's 8,652 elements, 150-330 ms across runs, where main restyles the document in two passes (about 17,800-19,900 elements, then 8,600-17,800) of 76-118 ms in all. Ruled out: the far-scene skip (switched off entirely, the pass takes 145-151 ms), unmounting scenes (switched off, the pass took 161 and 172 ms in 2 of 3 runs, 31 ms in the third) and selector matching (Chrome's selector statistics put it at 19 ms of a 245 ms pass). Why this PR's pass costs more per element is not found yet.
  • Cuts in films with [style*="visibility: hidden"] * rules. On the 50-second film's cut at 23.2 s with the film's own rule, the cut frame's task took 351 ms at e2bbe2d (one trace), almost all of it a 279 ms restyle of the 8,647 elements whose visibility the cut switches; 190-200 ms with the film's own rule removed at 8351fe3.
  • A scene mounted at open keeps a timeline it built for itself after a font wait. Its script runs with the film's scripts, where what a font wait resumes cannot be told apart by scene. Once it unmounts, a timeline its registered one drives is stopped, but one it keeps only for itself (built to pose the scene once, say) stays on GSAP's global timeline with the part of the scene it reaches. That happens once per scene mounted at open, not per mount: scenes mounted later record everything their scripts start until fonts settle.
  • A scene stays mounted when the swap gate refuses it, when its script also published its timeline under another scene's id, or when a media element or nested composition in it has no authored data-duration.
  • A position: fixed element inside a scene skipped from rendering measures against its host.
  • An earlier version of this description labelled a row measured after a seek as "first play after open". The tables above replace it.

Tests

Each new test fails with its part of the change removed; checked one by one.

  • farScenes.test.ts, coreRuntimeBrowser.test.ts (Chrome): far-scene marks follow play, cut, seek and scrub; measured boxes match with the skip on and off; render bundles get no marks.
  • farScenes.test.ts: a leaving scene's size and position come from its last render, with no getComputedStyle or offsetWidth read in the cut frame (fails on the earlier code with the read).
  • firstRenderReveal.test.ts, init.test.ts: a first render reveals only the clips its tweens reach.
  • htmlBundler.test.ts: the gate allows a project helper, the font-ready wait and named font loads, a canvas made only to measure text, a timeline chained where it is registered, a scene's own animate(t) and a GSAP file copied into the project; it still refuses timers, fetch, other awaits and promises, a named canvas or one handed back through .canvas, drawing on the stage's canvas, WebGL, WebGPU, bitmaprenderer and computed context types, chains that are never registered or are set aside first, bare GSAP tweens and Element.animate with timing. A helper that scenes share runs once for the film.
  • init.swapScenes.test.ts: far scenes park at boot without running their scripts; a seek waits for the scene it lands in; the last frame holds while it mounts, also when a frame draws between the mount's tasks; playing, the next scene mounts once its cut is within the lead and not at the cut before it; Studio holds scenes; the timeline keeps parked clips; edits to parked scenes (markup and styles) reach the next mount and unparkable edits are refused; parked scenes still mount after an edit to a mounted one; scenes whose clip lengths come from media stay mounted; a scene mounting during play joins the mix without a stop; decoded audio of a parked scene is released; Studio gets a parked scene's markup.
  • init.test.ts: Web Audio schedules a clip only as the playhead nears it, and none that ended; the hidden-clip lookup runs only on a jump where some tween draws for the first time.
  • init.swapScenes.test.ts: a mounting scene records what it starts after its font wait as its own, never the runtime's root timeline (which a rebuild can put back on GSAP's global timeline meanwhile); a Studio edit swaps a scene whose timeline drives one its script built later, and stops that one too (refused before: "an animation outside it moves its elements"); a scene whose timeline is also published under another id stays mounted; a mounted scene whose stand-in the visibility pass hid by display shows, also after it unmounts and mounts again.
  • coreRuntimeBrowser.test.ts (Chrome): a scene that draws on a 2D canvas is refused and never unmounts, its canvas unchanged after the window moves past it; a scene whose timeline is chained where it is registered unmounts and mounts again with the same transform.
  • webAudioTransport.test.ts: one fetch and decode per source; sources of media gone from the page stop without going to the destination.
  • coreRuntimeBrowser.test.ts (Chrome, GSAP, Web Audio running): after five seeks during play, the scenes the window mounted and left are freed, also when a scene's timeline drives one its script built after a font wait, and when its script builds one after a font wait to pose itself once (2,811 to 4,415 DOM nodes kept before the fix, under 800 after); each of the first five retainers, restored alone, keeps them.
  • Studio: sceneHold.test.ts, useDomSelectionSelectionGuards.test.ts (selection waits on a mount only when the preview has one), timelineSyncHydration.test.ts (rows of an unmounted scene).
  • At defa68e (this head before a merge of main and the producer budget change), on the Linux box: the full @hyperframes/core suite (5,293 passed, 1 skipped) and the Chrome contract file (77 passed), three runs each, and the core typecheck. The Chrome scene-memory test, which failed 3 of 10 runs under load before the last fix, passed 15 of 15 at load 11. The Studio suite, lint, format and the fallow audit run in CI.

Before

Main: playing across the cut at 630 s after a seek, GPU machine. The overlay is drawn by the page and counts frames per second.

before.mp4

After

This PR at the earlier head 4283542, same run.

after.mp4

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

@miguel-heygen miguel-heygen changed the title perf(core): skip rendering preview scenes far from the playhead perf(core): the preview mounts and draws only the scenes near the playhead Oct 24, 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: REQUEST CHANGES · 1 blocker · 1 important · 0 nits · head e1ab276a

The bar for this PR is play and scrub fps above main, and no scene cut more than 33 ms slower than main. Fps passes. The cut part fails on the body's own data:

  • blocker — cuts on the 50-second film are well over 33 ms slower than main. In the Scene cuts on the 50-second film table (taken at 7752323; the far-scene and reveal code has not changed since), the median cut frame is 222 vs 102 ms at 23.17 s (+120), 205 vs 88 ms at 30.5 s (+117) and 136 vs about 100 ms at 45.0 s (+36). The longest task is still +49 / +36 ms at 23.17 s and 30.5 s. The newer 23-minute film at this head cut table doesn't make up for this: it times the 50-second film's cut times (17.6, 23.17, 30.5, 30.7, 40, 45 s) on the 23-minute film, and the body says only one of those is a cut there. That film's real cuts, such as 630 s / 633.9 s, aren't measured. To clear this, either get those cuts under +33 ms here, or measure every cut at this head and show each one within +33 ms. If the plan is to accept the regression and fix it in the follow-up PR, that call belongs to whoever set the bar, not to this review.
  • The after-a-seek fps rows still show "third run running". Please fill them in before re-review.

CI was still running at review time (CI, regression, Windows render, CodeQL, Player perf); I haven't judged it.

Scope

Read end to end: runtime/init.ts (the window, mount/retire, seek, audio horizon and built-root changes), sceneWindow.ts, farScenes.ts, firstRenderReveal.ts, webAudioTransport.ts, timeline.ts, timedClipHide.ts. Checked how far I could trust them: the bundler gate, Studio sceneHold and hydration, and the tests. Confirmed the change since c17c3df2 is comments only.

const landed = windowScenes.find(
({ name, start, end }) => start <= time && time <= end && parked(name),
);
if (windowWork) return landed !== undefined;

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.

important — a seek can report done before its scene mounts. If a seek lands in a parked scene while another mount is already running, this line returns true and registers nothing with registerSeekCompletion. That happens whenever a neighbour mount is in flight, which during play is every scene 10 s ahead, and it takes about 165 ms. The finally only registers the landing later, so a __hfWaitForSeekCompletion call made in between returns while the old frame is still on screen. That breaks the body's promise that "a seek is not done until the scene it lands in is mounted". Fix: on this path, when landed is set, also register a promise that resolves once that scene has mounted, for example windowWork.then(() => <the landed scene's mount>). Add a test that seeks into scene B while scene A is mounting.

This branch has not been deployed

No deployments
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