Skip to content

fix(core): a scene callback that throws no longer stops the main timeline in render - #6322

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/render-keeps-timeline-after-callback-throw
Oct 25, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/render-keeps-timeline-after-callback-throw

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

A sub-composition whose GSAP callback (onUpdate, onComplete, ...) throws no longer freezes the main timeline's later animations in a render. Render now seeks scenes the way preview does, so the video matches preview, and the error is written to the render log once, as a warning, instead of being dropped.

Why

Repro: a scene whose onUpdate reads a missing element from 2 s on (TypeError: Cannot set properties of null), and a main-timeline tween at 3 s. Preview turns the box red at 3 s. hyperframes render exits 0 with a full-length video whose box stays blue to the end, and nothing in the log.

The two paths seek scenes differently:

  • Render (renderSeek, packages/core/src/runtime/init.ts:4690 on main) passed activateChildren: true, so activateSiblingTimelines (init.ts:5098) unpaused every scene timeline and GSAP rendered it inside the root's own totalTime() pass (init.ts:5249). A throw in the scene's callback aborts GSAP's child loop, so every root child after the scene in start order is skipped that frame. The catch around the root seek (init.ts:5266) passed it to swallow(), which is silent unless a debug flag is set.
  • Preview never rearms. The paused scene is skipped by the root's pass and seekStandaloneRegisteredTimelines (init.ts:5049) seeks it on its host's clock with a per-timeline catch, so a throw cannot reach the root.

The rearm predates that per-scene seek (#2615), which now seeks every hosted scene on its own source clock anyway, so in render each scene was seeked twice per frame.

How

  • renderSeek no longer rearms scenes; activateSiblingTimelines and the activateChildren option are gone. Render and preview go through the same seek.
  • seekRootChildrenAndAdapters seeks the scenes first (each paused, on its own clock), then the root. The root's pass skips the paused scenes, and the root's later tweens still land last on a property they share with a scene, the precedence fix(core): show a scene's starting state on a render's first frame #5614 pinned for render.
  • seekGsapTimeline only calls seek() on an object that has one, as player.ts does, so a placeholder registration (window.__timelines.scene = {}) is skipped instead of throwing on every frame.
  • A scene seek that throws is logged once per scene and message as a runtime warning, [hyperframes] scene timeline callback threw: "<id>" <error> (SCENE_CALLBACK_ERROR_PREFIX, runtime/diagnostics.ts, exported from @hyperframes/core). It is deliberately not a [HyperFrames] composition script error: line: the engine records those as page errors, and a page error turns a timed-out timeline wait into sub_timeline_script_failure (frameCapture.ts isScriptFailure) and cuts the wait short.
  • The engine already forwards [hyperframes] lines to the render log. It now writes this warning once per render rather than once per capture page, using the memo every session of a render shares.

What changes in output

Each change below was checked against what preview does on main today, with the same composition seeked through seek() (preview) and renderSeek() (render) on main's runtime and on this branch's.

  • The bug: later root tweens apply in render after a scene callback throws, as in preview.
  • A scene past its clip's end, in render: it now rests at its clip's end instead of following the root to the end of its own timeline. Its host is hidden there, so no pixels change. Callbacks match preview on main: one onUpdate on the first seek past the clip, and no onComplete of a tween longer than the clip (render on main fired both).
  • A timeline registered in window.__timelines with no element carrying its data-composition-id, nested into the root by the composition's own script and created paused: render used to unpause it on every frame, so it animated. It now stays paused, which is what preview on main shows. No registry component, block, skill template or producer fixture does this: every live window.__timelines registration in the repo (714 literal keys, 102 variable keys) resolves to its own host's or the root's data-composition-id. The only literal misses are placeholder keys inside usage comments.
  • Callback order within a frame: render is unchanged (a scene's callbacks, then the root's). Preview on main ran the root's first; it now matches render.
  • Preview, a property both the root and a scene tween: the first seek after load showed the root's value and every later seek the scene's. Every seek now shows the root's value, which render always did (fix(core): show a scene's starting state on a render's first frame #5614).
  • Render failure classes are unchanged. sub_timeline_script_failure is a script that throws before its timeline registers (frameCapture.ts recordSubTimelineWarning), and renderOrchestrator.ts fails the render on it. The new warning never enters the engine's page errors, so a callback throwing during a seek cannot change a readiness timeout into that class or shorten the wait; the engine test below pins it.

player.ts's non-transport seek path has its own copy of the rearm. The runtime always passes a transport (init.ts), so it is not on the render or preview path and is unchanged.

Measured

The repro above, rendered with this tree's CLI on an Apple silicon Mac, 24 fps, box colour and x position read from every frame:

box at 1 s / 3 s / 4 s / 5 s scene callback warnings in the render log
main blue / blue / blue / blue 0
this PR blue / blue / red / red 1
preview (player, 1x play), main and this PR blue / red at 3.03 s / red / red

Across all 144 frames the main box and the scene's dot sit at the same x before and after, within the 1 px a lossy draft encode allows once the box changes colour. Preview on this PR plays and seeks to the same values as main, and its console now shows the error once.

Producer regression suite (docker:test, linux/amd64, CI's 10-shard plan, run at 0222f6f): 54 of 54 fixtures pass, 0 failed frames in 53. sub-comp-t0 has 3 frames under its PSNR floor (0.119 s, 0.238 s, 2.8 s, within its allowance of 5); main at d4812a0 gives the same 3 frames with identical PSNR at all 100 checkpoints, twice, so they predate this change. Since then the branch was rebased onto main with #6189 (one conflict in seekRootChildrenAndAdapters, both changes kept); CI's producer regression run covers that head. The later commit only skips seeking a registration that has no seek() and changes no frames.

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

  • init.sceneCallbackError.test.ts (real GSAP): a scene whose onUpdate throws from 2 s, and a root tween at 3 s. renderSeek(4) and seek(4) both apply the root tween, and the error is warned once across four seeks with no console.error. Both fail on main (the root tween stays at 0; nothing is logged). Seeking scenes after the root again fails the first, and init.test.ts's root-over-scene precedence test from fix(core): show a scene's starting state on a render's first frame #5614; dropping the log line fails the second.

  • init.sceneCallbackError.test.ts: a scene registered as {} logs nothing across a render seek and a preview seek; fails without the seek() guard. scriptFailureAttribution.test.ts (producer) pins the same with a real page.

  • frameCapture.test.ts: two sessions of one render each receive the same scene callback warning twice; the render log gets it once and neither session records a page error. Fails with the dedupe disabled.

  • Updated, each failing on main's runtime: the invariant test now asserts the root's pass sees every hosted scene paused (pausedTransportInvariant.test.ts); the near-zero scene test asserts each scene lands at its own local time; the past-clip-end test asserts the scene rests at its clip end in render.

  • packages/core src/runtime + src/compiler: 123 files, 3,105 tests, after the rebase onto main with perf(core): the preview mounts and draws only the scenes near the playhead #6189; scriptFailureAttribution.test.ts (producer) 22 of 22, 3 runs in a row, exit 0. packages/engine: all pass except videoFrameExtractor.concurrency.test.ts, which fails the same way on main on this Mac.

  • Pre-commit lint, format, typecheck and fallow pass; comment-ratchet passes.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head 0222f6f4

The required Producer: integration tests check fails on this head (scriptFailureAttribution.test.ts › "keeps a scene's throw after it registered as a page error, never a load failure"). main is green at 26e3045. Cause and fix are inline.

Scope

Read init.ts (render seek, scene seek, root pass order), frameCapture.ts dedupe, types.ts, and the new and updated tests. Checked GSAP's paused-child skip, the restsAt skip (it requires paused(), so a skipped scene is still paused), and player.ts's remaining rearm (non-transport path only, as the body says). Core src/runtime vitest passes locally. Every other check that has finished is green (regression, preview-regression, CodeQL, perf).

Comment thread packages/core/src/runtime/init.ts Resolved
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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

  • seqheldtwice-tlhold-px-r0-nested-z100: tracking 120.29, pressJump 312.3, drop 0.07, reload 0.07, render 0.03, renderKey -, undo true, teleport false / tracking 0.41, pressJump 0.11, drop 0.39, reload 0.39, render 0.02, renderKey -, undo true, teleport true / tracking 0.41, pressJump 0.11, drop 0.39, reload 0.39, render 0.02, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 1 resolved, 0 open, 0 new · 0222f6f4 → 41881eb0. CI on this head is green, including producer integration tests.

@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 resolved, 0 open, 0 new · 41881eb0 → 0be2cd94. Scene callback errors are now a deduped console.warn, kept out of pageErrors, so render failure classes are unchanged. All Actions runs on this head are green.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 25, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 25, 2026
…line in render

Render seeks now leave each scene paused in the root's pass and seek it on its own clock first,
as preview does, so a throwing onUpdate cannot cut the root's tweens short. The error is logged
once per scene as a composition script error, and the render log shows it once per render.
… script error

A placeholder registration ({}) has nothing to seek. seekGsapTimeline now only calls seek()
when the object has one, as player.ts already does, so the new scene-error report stays for
real callback errors.
… failure classes

Logged as a composition script error, a scene callback that threw became a page error, which
turns a timed-out timeline wait into a script failure and cuts the wait short. It is now a
runtime warning the engine forwards to the render log once per render, outside page errors.
@miguel-heygen
miguel-heygen force-pushed the fix/render-keeps-timeline-after-callback-throw branch from 0be2cd9 to ffe9df7 Compare October 25, 2026 20:21

@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 resolved, 0 open, 0 new · 0be2cd94 → ffe9df77. Rebase only: git range-diff shows context changes alone, and the scene seek still runs inside the #6189 show/hide window. CI, regression, Windows render verification and CodeQL are green on this head.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit a6121fa Oct 25, 2026
124 of 125 checks passed
@miguel-heygen
miguel-heygen deleted the fix/render-keeps-timeline-after-callback-throw branch October 25, 2026 21:08
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