Skip to content

fix(core): composition scripts start after web fonts are ready, so renders repeat - #4980

Merged
miguel-heygen merged 13 commits into
mainfrom
fix/runtime-scripts-after-fonts
Oct 4, 2026
Merged

miguel-heygen merged 13 commits into
mainfrom
fix/runtime-scripts-after-fonts

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Composition scripts now start after the page's web fonts are ready (or after 5 s, with a diagnostic naming the fonts still loading). A script that measures text (offsetHeight, getBoundingClientRect, line counts for a layout) measures it in the real font on every run, so the same project renders the same frames every time, and Studio preview shows the layout the render will produce.

Why

Two renders of the same project could differ. A script that measured a block of text before its web font had loaded laid the scene out for the fallback font; on another run the font won the race and the layout moved. On cloud-render-launch (public launch films repo), main produced 4 different videos in 4 renders, with 632-701 frames differing and the worst frame at 23.9 dB PSNR (a visible layout shift).

Before

Studio preview at this PR's base. The composition's script sizes the blue frame to the headline's measured width. It runs before the Geist web font has loaded, so it measures the fallback font (1621 px) and the frame cuts through the last letter of the real headline (1675 px). Same result in 3 of 3 runs.

Before: the frame, sized while the fallback font was showing, stops inside the headline

After

Same project on this branch. The script runs once the font is ready, measures 1675 px, and the frame encloses the headline. Same result in 3 of 3 runs.

After: the frame, sized once the web font loaded, encloses the headline

Captured in Chrome 152 at 1440x900 from a fixture built for this PR (Geist Bold from the repo's catalog assets, one headline, one inline script).

Related work

None.

How

  • Compile time, every page builder (core bundler, producer compileForRender, Studio sub-composition preview, Studio motion script): body scripts get a type the browser does not run, text/hf-after-fonts (+module for modules), via one helper deferScriptsUntilFonts (core/src/compiler/scriptRuns.ts). The runtime, render-mode, bridge, JSON, importmap, template, svg and noscript scripts are left alone.
  • Runtime, one owner (core/src/runtime/afterFonts.ts, called from entry.ts): wait for document.fonts.ready (capped at FONT_WAIT_TIMEOUT_MS = 5000, past the 3 s a font-display: block face holds text, far under the engine's player-ready wait), then run the scripts in parser order (classic first, then modules and src+defer), awaiting each blocking external script's load, then boot the runtime. DOMContentLoaded/load listeners those scripts add after the events passed are held and fired after boot; a script that wraps addEventListener meanwhile keeps its wrapper. Scene swap and template compositions use the same wait.
  • Older runtimes: a page built by this compiler but served an older runtime (for example a pinned CDN runtime) still runs its scripts: an inline head fallback runs them at DOMContentLoaded unless the new runtime has claimed them.
  • Studio preview, MotionPathPlugin: the plugin tag Studio inserts after a body gsap tag takes that tag's type, so it still runs right after gsap. Studio's plugin preload (so the first motion path a user adds does not flash) used to run on the preview iframe's load, which can now come before a body gsap exists; it now waits for that preview runtime's ready message when gsap is not there yet. Only the latest preview waits, and a runtime that already booted is not waited on, so no retired preview document is kept alive.
  • CLI layout, motion-shot, validate wait for __renderReady (the runtime's own "booted after its scripts ran" signal, already used by snapshot) through one helper, waitForRuntimeReady, before they first sample the page, instead of __timelines, which exists as soon as the runtime file loads.

Measurements

A shared 8-core Linux machine, interleaved A/B, A = main, B = this branch at an earlier head with the same gate (later commits add the old-runtime fallback and the review fixes, which do not change a render on the current runtime). The machine was shared, so the 1-minute load is stated per table.

Determinism, cloud-render-launch, 2 rounds x (A, A, B, B), load 3-40:

distinct videos frames that differ worst frame
main 4 of 4 632-701 23.9 dB (layout shift)
branch 3 of 4 ~87 44 dB (anti-aliasing level)

The layout shift is gone. A second, much smaller source of variation remains (one region of ~87 frames, anti-aliasing level); it is not font timing and is left for its own change.

Studio open cost, 6 launch films x 5 runs each side, ready = the film's first frame shown, load 10-23:

film ready median A -> B (ms) ready p95 A -> B playReady median A -> B
claude-paper-launch 1177 -> 1022 3691 -> 2203 1258 -> 1078
cloud-render-launch 972 -> 980 1231 -> 1234 1089 -> 1035
figma-launch 1052 -> 1259 2528 -> 1633 1095 -> 1327
hyperframes-launch 1599 -> 1528 2624 -> 1892 2276 -> 2042
spacex-launch 1055 -> 954 2265 -> 1859 1127 -> 1011
website-to-hyperframes 835 -> 862 1099 -> 1403 913 -> 941

Medians move both ways by -155..+207 ms; no consistent added delay at this load.

Known limits

  • A font that never loads holds composition scripts for 5 s, once at boot and once per template composition mounted after it; a Studio scene swap that hits it falls back to a full reload.
  • External body scripts (for example gsap from a CDN) start downloading when fonts are ready rather than during parsing; the open-time table above shows no consistent cost.
  • A script using the "if the document is still loading, wait for DOMContentLoaded, else run now" idiom now runs before the runtime boots rather than after.
  • If a page loads the runtime twice, later scripts can run before an earlier external script has loaded. Nothing runs twice.
  • A Studio preview frame that never reports ready keeps at most one page alive until the next preview load.

Review

Independent adversarial reviews at each head after the rebase onto dda0e757b:

  • At the rebased head: no blocker or major. Fixed: the Studio plugin preload ran before a body gsap existed (now waits for the preview runtime's ready); the CLI test only proved the wait was called, not that it comes before sampling (now pins the order; moving the wait below the first sample fails it).
  • At the preload fix: one major, fixed. A composition with no global gsap never sent a later ready, so the waiting listener stayed and kept every replaced preview document in memory (7 after 6 reloads). The preload no longer waits once the runtime has booted (back to 1).
  • At that fix: one minor, fixed. A frame replaced before it ever booted (a crashing or missing composition) still kept its page; now only the latest preview waits.
  • At the last fix: no findings. One mutant survives harmlessly (clearing the pending slot when the retry fires only releases a reference earlier).
  • Found on the way, also on main, not changed here: Studio's plugin injection misses a body gsap whose URL has a query string (gsap.min.js?v=1) or a defer/async gsap tag; it gets its own change.

Test plan

  • Unit tests added/updated: scriptRuns.test.ts, entry.afterFonts.test.ts (order, font wait, timeout diagnostic, held listeners, a wrapped addEventListener survives, an inline script after an external one sees its global), compositionLoader.test.ts, init.swapScenes.test.ts (swap waits for fonts), htmlBundler.test.ts, producer htmlCompiler.scriptOrder.test.ts, studio-server subComposition.test.ts, routes/preview.test.ts (MotionPathPlugin follows a deferred gsap), studio gsapSoftReload.test.ts (preload waits for that preview's ready, stops at the first one, skips a booted runtime, drops a superseded wait), cli captureCompositionFrame.test.ts (wait precedes each command's first sample). Each changed test file run alone and green (the latest ones 3x, and in shuffled order); each fix reverted once turns its test red.
  • Manual: the Before/After captures above; real Chrome probes for script order, the plugin after a deferred gsap behind a slow font, and preview document counts across reloads. Determinism and open-time runs above.
  • Documentation updated (not applicable)
  • Comments follow CONTRIBUTING.md "Comments"

A scene script that measures text (offsetHeight, getBoundingClientRect) ran while
the page parsed, often before its web font loaded, so it laid the scene out with
fallback metrics. Renders of the same film differed run to run, and preview could
differ from export.

The compilers now give every body script a type the browser does not run
(text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called
at the end of the bundler, the producer compile and the sub-composition preview.
The Studio motion script is emitted the same way so it still runs after them.

The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for
document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script
in its own place in parser order (classic in order, awaiting src loads; then
module and defer scripts in insertion order), boots as before, and calls the
DOMContentLoaded and load listeners the scripts added. On timeout the scripts
run anyway and runtime_font_wait_timeout names the families still loading.
Scene swaps and the composition loader use the same wait.
… scripts

A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that
runtime is older than the font gate, nothing would run the deferred composition
scripts and the film would be blank.

Whenever the marking helper defers scripts, it also puts one small inline script
first in the head. At DOMContentLoaded it runs the deferred scripts in parser
order itself, without waiting for fonts and with a console warning, unless a
runtime has claimed them. The gate's runtime claims them when it loads, before
DOMContentLoaded, so the scripts run exactly once either way.
…loads in the body

The MotionPathPlugin tag Studio inserts after a body gsap script now carries
that script's type, so the runtime runs it right after gsap once fonts are
ready, instead of before gsap exists.
…cripts to run

The three commands waited on the timeline registry, which exists as soon as the
runtime file loads, or on a flat sleep. They now wait for the runtime's
render-ready flag through one shared helper, keeping each command's timeout and
proceeding as before when it passes.
…er its scripts run

While composition scripts run, the runtime patches addEventListener to hold
load events that already passed. Putting the original back now happens only if
the patch is still in place, so a library wrapper installed meanwhile survives.
The early stub's flush and the player's iframe-load handler described page
scripts as done by DOMContentLoaded and the runtime as ready before load.
Composition scripts now wait for web fonts, so both can come later. Comment-only.
…wap's font wait

An inline composition script that follows an external one sees its global, and
a scene swap runs the new scene's script only once web fonts are ready.
@miguel-heygen
miguel-heygen force-pushed the fix/runtime-scripts-after-fonts branch from c04889a to a61a973 Compare October 4, 2026 06:54
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 1556 (base branch 1556), smooth 1456 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)

…eb fonts

Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready.
…untime has booted

A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter.
…e motion path preload

Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected.

@terencecho terencecho 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.

Approving add7304c. Composition scripts now run after web fonts are ready, in parser order, and I found no defect that should block the merge. Three small ones are listed below.

What I verified

  • Captures: the Before shows the frame sized to 1621 px, stopping inside the last letter of "Measured headline"; the After shows 1675 px and the frame encloses it. Both are 1440x900 and match the PR body.
  • Tests: the changed test files pass 3 of 3 runs, with NODE_ENV=test and the dependency packages built: runtime 215/215, core compiler 142, studio-server 102, studio 53, producer 5, CLI 29.
  • Real Chrome 152, runtime built from this head, pages compiled with deferScriptsUntilFonts:
    • A font stalled past the cap resumes the scripts at ~5.05 s, names the family in the diagnostic, and the runtime boots after them.
    • An inline script after a slow external script sees its global.
    • A classic script, an inline module and a src module all run before the runtime boots, in the intended order.
    • A throwing script and a 404 external script do not block later ones.
    • With the runtime loaded twice or async, and with only the old-runtime head fallback, nothing runs twice.
    • Held DOMContentLoaded/load listeners fire exactly once.
  • Compile side: a retype matrix over classic, nomodule, async/defer src and type=module (to +module), and untouched JSON, importmap, text/babel, noscript, svg, template, framework-attribute and head scripts. It is idempotent on a second pass. Nothing downstream reads the script type (probeStage, the orchestrator scans, the WebGPU check and the lint rules read text or the source HTML).
  • Mutants: 32 mutations across the runtime and compile-time lanes; 25 are caught (no font wait, reversed order, no timeout, no listener replay, external load not awaited, never holding, boot before scripts, wrapper clobber, loader and swap without the wait, framework-skip removed, module suffix dropped, each page builder's defer removed, plugin type carry-over dropped, old-runtime fallback not inserted, layout/validate wait moved or removed).
  • CI at this head is finished: 93 pass, 1 skipped (catalog), 0 failing, including the 10 regression-shards, Studio: edit accuracy gate (1556 accurate, same as base) and the Windows jobs. It is MERGEABLE, 12 commits behind main, no conflicts.

Non-blocking, worth a follow-up

  1. window.onload = fn set by a composition script is lost once load has already fired (a slow font holds load). The hold wraps addEventListener only, so the property handler never fires; addEventListener("load", …) is replayed. I reproduced it in Chrome (font served after 2.5 s: base fires onload, head does not), and readystatechange handlers are the same. It is not in "Known limits"; replaying window.onload or adding a line there would close it.
  2. Inline template mounts wait for a stalled font one after another: with 3 templates the scripts ran at 5.17 s, 10.17 s and 15.18 s. Extrapolating, about 9 templates would pass the engine's 45 s player-ready wait; skipping the wait after the first timeout would fix it. Not run past 3.
  3. preview.ts (injectStudioMotionScript) escapes only </script; the old injectScriptsIntoHtml path also escaped <!-- (escapeInlineScriptSource, not exported). A manifest string containing <!--<script makes the parser swallow what follows the script. It needs an odd manifest string and affects Studio preview only; reusing the old escape fixes it.

Test gaps (the 7 survivors: 5 in the runtime lane, each correct in the real-Chrome run above): removing the afterQueuedModules wait, running modules before classic scripts, dropping src+defer from the late set, waiting on fonts even with no deferred scripts, and rethrowing a held-listener error instead of reportError. Also no test pins the </script escape in preview.ts, or an uppercase type. The CLI wait test is a source-text scan.

What I did not exercise: a full producer render before/after (the render path is inferred from the green regression-shards), a scene swap interrupted by a second swap during the font wait, and Studio's soft-reload preload in a browser (read from source and tests).

This is not authorization to merge or deploy beyond what the gate already does.

— Review by tai (pr-review)

@somanshreddy somanshreddy 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.

Review at add7304c (full PR). This is a comment, not an approval. I agree with tai's review (5405480821) and don't repeat it here.

Timeline and duration on the current runtime: no problem found. entry.ts:62-66 boots the runtime only in runScriptsAfterFonts's afterRun, which runs after the scripts and after the module sentinel (afterFonts.ts:118-125). In the producer, the scripts' tweens go into the early-stub queue and set __hfTimelinesBuilding. maybePublishRenderReady (init.ts:4116-4126) keeps __renderReady false until hf-timelines-built fires and the external compositions have mounted. The engine's early __hfFlushSync (frameCapture.ts:2326/2488) now runs before the scripts and does nothing, but duration() drains the queue later. Probe in real Chrome 152 with the head runtime and a gsap timeline after a slow external gsap: at __renderReady the duration is 3 s, both for the compiled page and for the same page uncompiled.

1. (medium, non-blocking) A page built by this compiler and served the main-branch runtime is ready before its timeline exists. The fallback (scriptRuns.ts:49-69) is registered first at DOMContentLoaded. It chains through the external scripts on their load events and then returns. The old runtime's own DOMContentLoaded boot runs right after, before the external gsap has loaded. Same probe, runtime built from main: __renderReady was true at 116 ms with getDuration() = 0. Periodic rebinding brought it to 3 s by about 2.5 s. With the uncompiled page and the same runtime, __renderReady came at 573 ms with a duration of 3 s. The producer is not affected, because it serves its own current runtime. Pinned CDN runtimes are affected: anything that reads the duration at ready gets 0. One possible fix is to have the fallback hold the old boot until its queue is empty, for example by running the queue from a head script that the old runtime's listener waits on. Otherwise, add this to Known limits.

2. (low) init.ts:3697: the new await runScriptsAfterFonts(sceneScripts) comes after the swap has already changed the page. sceneSwapGeneration is bumped at :3629, so a second swap that starts during the wait passes the generation check at :3605 and writes over a swap that is only half done. Only tornDown is checked again after the await. The window is close to zero unless the swap brings in a new font face.

3. (low, the claim's scope) Only fonts already in use are awaited. fonts.ready ignores faces that are still unloaded. In the probe, text that the script itself creates in a declared face measured 612 px with the fallback, against 489 px once the font loaded, and canvas.measureText did the same. Text already on the page measured correctly. This is not a regression. Options: also load() the faces still unloaded within the same 5 s, or narrow the claim in the PR description.

tai's points, checked at source: (1) confirmed. holdPassedLoadEvents (afterFonts.ts:47-68) wraps only addEventListener, so a window.onload = set after load has fired never runs. (2) confirmed. Template mounts run one after another (compositionLoader.ts:632-641) and each waits up to 5 s (:558). playerReadyTimeout is 45 s (engine/config.ts:320). (3) confirmed. preview.ts:191 escapes only </script.

CI: 93 pass, 1 skipped; 11 of 11 required checks pass.

What I ran: real-Chrome probes with runtimes built from the head and from main: script order and timing, timeline duration at ready, a stalled font, and measuring script-created text. I also ran the changed test files in core, studio, studio-server, producer and cli. init.swapScenes and preview tests would not load on this machine because of a known node: setup issue, so those two did not run. 14 mutants: 11 caught. The 3 that survived are jsdom limits (the layout nudge and the module sentinel) and the bundler's runtime-tag predicate. One static Codex pass hit its 10-minute limit before it gave final output. Its two partial leads match findings 1 and 2, and I checked both myself. The Before/After captures would not load for me.

— Somu

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 44b5836 Oct 4, 2026
189 of 191 checks passed
@miguel-heygen
miguel-heygen deleted the fix/runtime-scripts-after-fonts branch October 4, 2026 10:37
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.

3 participants