Skip to content

fix(engine): a render fails when a composition's own script throws before its timeline binds - #5033

Merged
miguel-heygen merged 21 commits into
mainfrom
fix/render-fails-unbound-timeline
Oct 5, 2026
Merged

miguel-heygen merged 21 commits into
mainfrom
fix/render-fails-unbound-timeline

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What this fixes

A composition whose own script throws before it registers its GSAP timeline (for example, it calls gsap.timeline() above the <script> that loads GSAP) rendered as a still video and the CLI exited 0. The page error was printed as [Browser:PAGEERROR] and nothing acted on it, so the render waited out the 45 s timeline wait and shipped a static output with only a sub_timeline_readiness_timeout warning. Distributed renders shipped the still video even for a timeline script that failed to load, which local renders already reject.

Cause

  • A script failure already fails a local render: the orchestrator raises RenderQualityError on a sub_timeline_script_failure warning (decision-tree example renders 2 unique frames, and check + render both report success #3352). Only two routes produced that warning: a script that failed to load, and the [HyperFrames] composition script error: console line a sub-composition's script prints when it throws. An uncaught error in a composition's own top-level script was only logged, and the render compiler printed a sub-composition's throw under a different label ([Compiler] Composition script failed), so that route never matched in a render either.
  • renderChunk collected every capture session's warnings and never applied the render warning policy to them.

Change

  • Scripts keep their names on the render path. The compiler inlines every CDN script and the runtime re-runs body scripts once fonts load, so Chromium reported their code with no URL and could not tell a widget from the composition. Each such script now ends with a //# sourceURL= name: an inlined CDN script keeps its original URL, normalised through new URL(src).href (inlineExternalScripts); the framework's injected scripts (early stub, runtime, render mode, bridge) are named hyperframes://injected/N (N counts within each injection), and the compiler's own after-fonts fallback, position-edit and position-reapply scripts are named hyperframes://… too, all off the page's origin. A later directive wins over one the code already carries. The render compiler (prepareCompositionScripts, before deferring body scripts) names every other inline classic or module script hyperframes-composition://body (COMPOSITION_SOURCE_URL): the composition's head and body scripts and its sub-compositions' scripts. A script that already ends with its own name, such as an inlined CDN script, keeps it. Only the render compiler does this, so Studio preview and scene swapping, which read live script text, see it as authored.
  • Which errors count. The engine listens to Runtime.exceptionThrown on the page's CDP session and keeps an uncaught error only when it came from the composition: a frame of its stack is named hyperframes-composition://, or is a script file the page loaded from the render file server (a script response under 400 from the server, recorded in initializeSession), or, for an error with no stack (a parse error), the script file it names is one of those. The document URL does not count as such: an inline event handler reports the current document, and a widget can insert one and move the document anywhere on the server with replaceState (measured). A widget that moves it onto the path of a script the page already loaded is still counted (contrived; not handled). That covers inline scripts in the composition document, the project's script files, and foreign code running inside a composition call (a CDN library the composition calls, or a foreign listener its own dispatchEvent runs). Errors from scripts on other origins (a third-party widget) are not kept. A promise rejection raised by a browser API (img.decode(), Response.json()) arrives with no stack, naming only whatever document is current (after a hash change, a replaceState, or inside an iframe, that URL changes), whichever script caused it, so no stackless rejection is kept. classifyPageError is the one place that decides. The CDP event is used rather than Puppeteer's pageerror because the latter drops the script URL of syntax errors and of thrown non-errors (measured in Chromium 152). The benign play/pause race is still ignored.
  • When they fail the render. Errors never cut the timeline wait short: a page may throw and still register its timeline later, as the timeout warning tells async compositions to do. When the wait times out with a kept error, recordSubTimelineWarning records sub_timeline_script_failure instead of sub_timeline_readiness_timeout, naming the error and the data-no-timeline remedy for a composition animated by CSS or rAF. The wait outcome itself stays timeout, so telemetry keeps its meaning.
  • Distributed renders. renderChunk passes its capture warnings to applyRenderWarningPolicy, the function the local renderer calls after capture, so a chunk throws RenderQualityError where a local render fails. Chunks run that policy as best-effort (their job carries no strictness), so they fail on script and audio failures as a local best-effort render does.
  • Sub-compositions. The render compiler drops its own error label, so a sub-composition's script that throws prints the [HyperFrames] composition script error: label the engine matches. That logged throw is now a kept page error like an uncaught one (classifyConsoleScriptError): it fails the render only when the timeline wait times out. On main that label took the 2 s script-load fail-fast, which also cut the wait when the scene had already registered its timeline and the root registered later; an integrity-blocked script keeps the fail-fast. Cost: a composition script that throws through a framework wrapper (scoped sub-composition, VFX) and never registers now fails after the full wait, about 94 s end to end (the probe session and the capture session each wait 45 s), instead of about 6 s. A VFX error is not one of these: see the next bullet.
  • VFX. A failed VFX chain does not draw its layer at all (measured: a chain naming an unknown effect leaves every sampled pixel of that layer as the page background, where the working chain shows the warped layer; main ships it with exit 0). The engine records the first VFX error (runtime-error:vfx: ...) as a vfx_failure warning and stops the render at once: the timeline wait ends on it, no further frame or frame batch is captured, and the render warning policy fails on it like a script failure, which also covers an error from the last frame and chunk renders. Only chain-level errors stop it: a chain that does not parse, an unknown effect, a shader or program that does not build, no WebGL2, a missing ref element or capture structure, a lost context (there is no restore path), or a drawElementImage that failed on the render's authoritative capture (that frame would ship stale). Those leave frames wrong. Two per-frame reports can leave correct frames, an empty capture box and a paint wait that timed out, so vfx.ts reports them as vfx-frame: and they stay page errors. A frame whose wrapper measures 0×0 now paints from an empty capture, the same path a hidden source takes, instead of repeating the previous frame. And a wrapper that measured 0×0 when the runtime registered it or at a seek is re-inserted into its canvas before the next paint at which it has a size, whether or not anything captured it at 0×0: in the runtime's capture setup, Chrome 152 can draw nothing of it, even after requestPaint, until it is re-inserted (measured through the runtime and the CLI; a bare layoutsubtree canvas does not show it). Both marks matter: GSAP applies a fromTo start value lazily, on its next tick, so in the CLI's script order the runtime registers the wrapper at its CSS size and only the seek sees it at 0×0, and a wrapper that is 0×0 at load but sized before the first seek is only seen at registration. On main such a layer stayed missing for the whole shot, including when it was hidden or its host started later while it was 0×0. Measured per frame: a wipe-in from width 0 draws from frame 1, a jump from 0 draws from the frame it is sized, a wrapper hidden at 0×0 or under a host that starts later draws once shown and sized, a dip to 0 recovers, a shrink to 0 is empty from that frame, and a wrapper that is always 0×0 stays empty (as in preview) with a page error. CLI: a broken chain fails in 3 s naming the error, also when a timeline wait is pending (about 94 s before this change); a working chain renders as before. A VFX error that arrives after the last frame, or in the HDR path (which seeks without these checks), still fails the render through the warning policy before the output is assembled.
  • When a script failed to load and the composition then threw because of it, the warning names the load failure, not the error it caused.

Behaviour change: a composition with no timeline that is not marked data-no-timeline now fails after the 45 s wait when one of its own scripts throws, where before it warned and shipped. An error from a script on another origin still only warns.

Errors that cannot be attributed still ship a still video as on main, with the readiness warning: browser API rejections as above, inline parse errors (on the render path they carry only runtime frames), data:/blob: scripts, inline event handlers and javascript: URLs in the composition's own markup (they report the document), and an authored script that ends with its own sourceURL name. Code built from text at runtime (eval, new Function, an inserted inline script, a string setTimeout) has no URL in Chromium, so it counts only through a named frame below it on the stack: a widget's synchronous eval names the widget (not kept), the composition's synchronous eval names the composition (kept). Thrown later, from a timer or callback inside such code, it has no named frame and no creator in V8's stack text, so it is not kept, whoever wrote it (measured in Chromium 152).

Retry cost: AWS and GCP retry a failed chunk up to 4 times, and RenderQualityError stays retryable here, because a CDN script that failed to load shows up as an authored error (gsap is not defined) and can succeed on retry. A real throw therefore costs up to 4 retries of the 45 s wait before the distributed render fails.

Known cost of a VFX stop: auto-worker calibration logs it and tries once more in a second browser (about 1 s), and a composition routed to drawElement capture retries the render once before failing.

Known limit: plan-level strictness: "strict" does not reach chunks, so a distributed strict render still fails only on script and audio failures; carrying it needs a plan format change.

Evidence

CLI renders on this branch (main in the first row; on main every page error was only logged):

Composition main this branch
Builds its timeline before loading GSAP (inline script throws) exit 0 after 95 s, 4 KB still MP4, warning sub_timeline_readiness_timeout exit 1 after 100 s, no output, sub_timeline_script_failure naming the error
Inline async setup throws after an await exit 0 exit 1 after 94 s, no output, sub_timeline_script_failure naming the error
Throws an unrelated error at load, registers its timeline after 3 s exit 0 exit 0 after 8 s, animated
CSS animation, no timeline, a script from another origin throws from a timer exit 0 exit 0 after 93 s, animated, readiness warning
CSS animation, no timeline, a script from another origin throws at load exit 0 exit 0 after 94 s, animated, readiness warning

How Chromium 152 reports each error through Runtime.exceptionThrown (probed with a real page served from one origin, a widget from another; on the render path the inlined and injected scripts carry these URLs through their sourceURL names, and the composition's inline scripts report hyperframes-composition://body):

Error Script URL Stack frames Kept
Inline throw (sync, timer, rejected promise) composition composition yes
Project script file throws project file project file yes
Project script file does not parse project file none yes
Inline script does not parse, or a browser API rejects (img.decode(), r.json()) composition none no
throw "string" inline composition composition yes
CDN library throws when the composition calls it CDN CDN, composition yes
Widget from another origin throws other origin other origin no
Inline handler a widget inserts throws (also after replaceState) current document current document no

Tests:

  • scriptFailureAttribution.test.ts (new, integration lane, real Chromium on the render path: compileForRender, writeCompiledArtifacts, the producer file server): a timeline that never registers fails with sub_timeline_script_failure when an inline script throws at load or after an await, a project script throws, a project script does not parse, or a project script is missing; an inline script in the head throws, or a sub-composition's inline script throws; a scene that throws after registering its timeline is kept as a page error and never as a load failure, so it cannot cut the wait short; a VFX chain naming an unknown effect fails the render, while a working chain and a wrapper with no size do not; in vfxDeterminism.test.ts a layer that grows from 0×0, or is hidden at 0×0 and then shown and sized, draws on the next frame, one sized at registration but first painted at 0×0 (a GSAP fromTo) draws once it grows, one at 0×0 under a host that starts later draws once the host shows, one at 0×0 at load and sized before the first seek draws on that seek, and one that shrinks to 0×0 draws empty; it stays sub_timeline_readiness_timeout when a script from another origin (inlined by the compiler) throws at load, throws from a timer, makes img.decode() reject, changes the hash and then makes it reject, evals a throw, evals or inserts code that throws later, throws from a string setTimeout, inserts markup whose inline handler throws, or moves the document with replaceState first; and a composition that evals its own throw fails. Red-first at each step: with the engine before the VFX change both the VFX case and the scene case fail; with the sources before that (origin rule, runtime naming) the sub-composition case and both handler cases fail; with the sources that counted unnamed code, the four widget runtime-code cases fail; with the sources before scripts were named, the await case and the widget-at-load case fail.
  • renderChunk.test.ts: a real chunk render (plan, then renderChunk) of a composition whose timeline script is missing rejects with RenderQualityError naming sub_timeline_script_failure; with main's renderChunk it resolves.
  • frameCapture.test.ts: classifyPageError on Chromium-shaped exception details (each row of the table above, and the play/pause race), and the CDP listener wiring through initializeSession.
  • frameCapture-subTimelineWarning.test.ts: a timeout with a kept error becomes sub_timeline_script_failure naming data-no-timeline and leaves the outcome timeout; registered timelines record nothing; a load failure is named over the error it caused.
  • Single-line mutations: in the engine units and the real-render test, accepting errors from any origin, counting a frameless error that names the document, counting a stackless rejection, and ignoring a frameless error's script URL each turn a case red; on the render path, leaving injected scripts unnamed (widget at load), leaving inlined CDN scripts unnamed (widget at load and from a timer), counting unnamed frames (the four widget runtime-code cases), the compiler not naming composition scripts (inline throws at load, after an await, in the head, and the composition's own eval), overriding a name a script already carries (widget at load, from a timer, and its eval), counting the document URL (both handler cases), restoring the compiler's own error label (the sub-composition case), dropping the logged throw (the sub-composition case), treating a VFX error as a page error (the VFX case), stopping on a per-frame empty box (the no-size wrapper case), repeating the last frame on a 0×0 wrapper (the shrink case), dropping the re-insert (the grow, GSAP, hidden, late-host and at-load cases), not marking a wrapper that registers at 0×0 (the at-load case), marking it only at registration (the GSAP and late-host cases), skipping hidden hosts in the per-seek walk (the late-host case), dropping the per-frame VFX check, dropping the early stop of the timeline wait, and leaving vfx_failure out of the warning policy each turn cases red.
  • 3 green runs of scriptFailureAttribution.test.ts (22 cases, 5 green runs with the box under load), vfxDeterminism.test.ts (31 cases, 3 green runs); core compiler and runtime suites (2347, scene swapping included), engine units, producer compiler and file-server suites, and the renderChunk.test.ts case pass.

Before

hyperframes render on main, the composition that builds its timeline before loading GSAP:

[Browser:PAGEERROR] Cannot read properties of null (reading 'timeline')
[FrameCapture] Sub-composition timelines not registered after 45000ms: main. ...
[FrameCapture:sub_timeline_readiness_timeout] Sub-composition timelines did not become ready within 45000ms (still unregistered: main). This can be intentional: ...
[WARN] Render completed capture with correctness warnings {"strictness":"best-effort","warningCodes":["sub_timeline_readiness_timeout"]}
exit 0 after 95 s, a 4 KB MP4 of the first frame

After

[Browser:PAGEERROR] Cannot read properties of null (reading 'timeline')
[FrameCapture:sub_timeline_script_failure] A composition script threw and a timeline did not register within 45000ms (still unregistered: main) (runtime-error:TypeError: Cannot read properties of null (reading 'timeline')). Fix the error; a composition animated by CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.
✗  Render failed
   Render blocked by 1 correctness warning:
   - sub_timeline_script_failure: A composition script threw and a timeline did not register within 45000ms (still unregistered: main) (runtime-error:TypeError: Cannot read properties of null (reading 'timeline')). Fix the error; a composition animated by CSS or rAF rather…
exit 1 after 96 s, no output file

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2040 (base branch 2040), smooth 1554 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 (3)

  • crop-spin-px-r30-root-z50-on: tracking 0.05, pressJump 0, drop 0.15, reload 0.1, render 0.27, renderKey 0.21, undo false, teleport true / tracking 0.05, pressJump 0, drop 0.15, reload 0.1, render 0.27, renderKey 0.21, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.15, reload 0.1, render 0.27, renderKey 0.21, undo true, teleport true
  • nudge-scale-px-r30-nested-z200-after: tracking 0.01, pressJump -, drop 0, reload 0.03, render 0.09, renderKey 0.09, undo false, teleport true / tracking 0.01, pressJump -, drop 0, reload 0.03, render 0.09, renderKey 0.09, undo true, teleport true / tracking 0.01, pressJump -, drop 0, reload 0.03, render 0.09, renderKey 0.09, undo true, teleport true
  • resize-spin-px-r0-nested-z200-mid: tracking 0.23, pressJump 0, drop 0, reload 0.05, render 0.08, renderKey 0.07, undo false, teleport true / tracking 0.23, pressJump 0, drop 0, reload 0.05, render 0.08, renderKey 0.07, undo true, teleport true / tracking 0.23, pressJump 0, drop 0, reload 0.05, render 0.08, renderKey 0.07, undo true, teleport true

@miguel-heygen miguel-heygen changed the title fix(engine): a render fails when a composition script throws before its timeline binds fix(engine): a render fails when a composition's own script throws before its timeline binds Oct 4, 2026
@miguel-heygen
miguel-heygen force-pushed the fix/render-fails-unbound-timeline branch from ce35e95 to 1c26ced Compare October 5, 2026 00:37
Comment thread packages/core/src/compiler/scriptRuns.ts Fixed
Comment thread packages/core/src/compiler/scriptRuns.ts Fixed
Comment thread packages/core/src/compiler/scriptRuns.ts Fixed
@miguel-heygen
miguel-heygen force-pushed the fix/render-fails-unbound-timeline branch from e90006d to bf6a948 Compare October 5, 2026 06:30
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 15:51

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at d22b01eb. Approving.

Scope of the new failure (the production risk):

  • A render fails only when both hold: the timeline wait times out, and a page error was kept (recordSubTimelineWarning, frameCapture.ts:2024). Errors never cut the wait short, so a composition that throws and then registers, or throws after it registered, still renders. The "registers after 3 s" row and the "scene throws after registering" case cover this.
  • Who owns an error: only code the composition owns produces a kept error (classifyPageError, frameCapture.ts:2261). That means a stack frame named hyperframes-composition://, or a script file served by the render file server with a status under 400. Other code is ignored:
    • the framework's injected scripts (hyperframes://injected/N, htmlDocument.ts:205);
    • inlined CDN scripts, which keep their CDN URL (htmlCompiler.ts:1261);
    • other origins;
    • stackless rejections;
    • inline handlers.
  • Inline vs src: an inline script is named by prepareCompositionScripts. A src file from the project counts through the response set. A src from a CDN is inlined under its own URL, so it only counts when a composition frame is below it on the stack. I don't see a way to make a widget's error count as the composition's, short of the widget writing the composition's sourceURL itself.
  • Local and distributed are the same: renderChunk now runs the same applyRenderWarningPolicy after capture (renderChunk.ts:928). RenderQualityError is retried at most 4 times by AWS/GCP, then the render fails, so a failing chunk doesn't retry forever.
  • Code scanning: the three code-scanning alerts from a432781d on scriptRuns.ts (1126–1128) are marked fixed at this head. That file now only adds a constant and a fixed sourceURL line.

Reuse:

  • renderChunk calls the existing warning policy rather than a chunk copy, as plan.ts already does.
  • The CDP listener uses the engine's cached getCdpSession.
  • The sub-composition path reuses the existing [HyperFrames] composition script error: label.
  • isJavaScriptType is exported from core rather than re-written.
  • The readiness/getReadyPromise pattern doesn't fit here: this decides whose error it was, not when the page is ready.

Tests at this head:

  • Engine frameCapture + sub-timeline suites: 70 of 70.
  • Core htmlDocument + vfx: 101 of 101.
  • Producer scriptFailureAttribution + vfxDeterminism + renderOrchestrator: 337 of 337, with real Chromium.
  • renderChunk.test.ts: 15 of 15.
  • Removing the VFX re-insert fails 5 of the 31 vfxDeterminism tests, so those tests do exercise it.

Non-blocking:

  1. The VFX re-insert restarts CSS animations under the wrapper for one frame. reinsertRegrownSources (vfx.ts:1323) runs inside paintVfx, which renderSeek calls after the CSS adapter has seeked and paused (init.ts:3868-3876). A remove and re-insert in Chrome 152 discards a seeked CSS animation and starts a new running one at 0. I measured opacity going from 0.5 at currentTime 5000 to 0 at currentTime 0, as a new running animation. So on the frame a wrapper regrows from 0×0, CSS-animated children inside it are captured at their start pose. moveBefore keeps the state (0.5, same animation, paused), and with moveBefore swapped in, vfxDeterminism still passes all 31. Preview also runs this in browsers without moveBefore, so it needs moveBefore ?? insertBefore.
  2. A kept error isn't tied to the composition that failed to register. Any kept error plus any unregistered timeline fails the render. For example, a project with a harmless inline throw (an analytics snippet pasted inline counts as the composition's) and an unmarked CSS-only sub-composition shipped on main and now fails after the wait. The description states this as the intended behaviour change. It's worth checking how often sub_timeline_readiness_timeout comes with a [Browser:PAGEERROR] in production renders before this ships widely.
  3. Simpler: the VFX half is a separate failure mode from the script attribution: the vfx_failure stop, the empty paint at 0×0, and the regrow re-insert. That's about half the commits, vfx.ts and the vfxDeterminism cases. As its own PR it would be easier to review and to revert. The script-attribution half earns its size: each sourceURL site exists so a widget's error isn't counted, and each has a case that fails without it.

Verdict: APPROVE
Reasoning: A render now fails only when the timeline never registers and the composition's own code threw. Third-party, injected, and unattributable errors still only warn, and chunk renders apply the same policy as local ones. The suites are green, including the real-Chromium attribution cases, and the one-frame CSS restart in the VFX re-insert has a one-line fix.

— Rames Jusso

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 39a693c Oct 5, 2026
170 checks passed
@miguel-heygen
miguel-heygen deleted the fix/render-fails-unbound-timeline branch October 5, 2026 17:03
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