Repository navigation
fix(core): a scene callback that throws no longer stops the main timeline in render - #6322
Conversation
There was a problem hiding this comment.
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).
Edit accuracy: accurate 271 (base branch 271), smooth 175 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
…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.
…diagnostics export
0be2cd9 to
ffe9df7
Compare
There was a problem hiding this comment.
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.
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
onUpdatereads 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 renderexits 0 with a full-length video whose box stays blue to the end, and nothing in the log.The two paths seek scenes differently:
renderSeek,packages/core/src/runtime/init.ts:4690onmain) passedactivateChildren: true, soactivateSiblingTimelines(init.ts:5098) unpaused every scene timeline and GSAP rendered it inside the root's owntotalTime()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 toswallow(), which is silent unless a debug flag is set.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
renderSeekno longer rearms scenes;activateSiblingTimelinesand theactivateChildrenoption are gone. Render and preview go through the same seek.seekRootChildrenAndAdaptersseeks 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.seekGsapTimelineonly callsseek()on an object that has one, asplayer.tsdoes, so a placeholder registration (window.__timelines.scene = {}) is skipped instead of throwing on every frame.[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 intosub_timeline_script_failure(frameCapture.tsisScriptFailure) and cuts the wait short.[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
maintoday, with the same composition seeked throughseek()(preview) andrenderSeek()(render) onmain's runtime and on this branch's.main: oneonUpdateon the first seek past the clip, and noonCompleteof a tween longer than the clip (render onmainfired both).window.__timelineswith no element carrying itsdata-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 onmainshows. No registry component, block, skill template or producer fixture does this: every livewindow.__timelinesregistration in the repo (714 literal keys, 102 variable keys) resolves to its own host's or the root'sdata-composition-id. The only literal misses are placeholder keys inside usage comments.mainran the root's first; it now matches render.sub_timeline_script_failureis a script that throws before its timeline registers (frameCapture.tsrecordSubTimelineWarning), andrenderOrchestrator.tsfails 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:
mainmainand this PRAcross 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-t0has 3 frames under its PSNR floor (0.119 s, 0.238 s, 2.8 s, within its allowance of 5);mainat 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 ontomainwith #6189 (one conflict inseekRootChildrenAndAdapters, both changes kept); CI's producer regression run covers that head. The later commit only skips seeking a registration that has noseek()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 whoseonUpdatethrows from 2 s, and a root tween at 3 s.renderSeek(4)andseek(4)both apply the root tween, and the error is warned once across four seeks with noconsole.error. Both fail onmain(the root tween stays at 0; nothing is logged). Seeking scenes after the root again fails the first, andinit.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 theseek()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/coresrc/runtime+src/compiler: 123 files, 3,105 tests, after the rebase ontomainwith 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 exceptvideoFrameExtractor.concurrency.test.ts, which fails the same way onmainon this Mac.Pre-commit lint, format, typecheck and fallow pass;
comment-ratchetpasses.