diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fd5a9c437..af24a6b59 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -635,7 +635,27 @@ export function initSandboxRuntimeModular(): void { } } }; - if (rootChildCandidates.length > 0) { + // In render mode (producer's seek-driven frame capture, signalled by + // `window.__HF_VIRTUAL_TIME__`), child scenes MUST be un-paused: GSAP's + // `totalTime` cascade propagates from master to each nested child, but a + // child that's individually paused won't render at its nested time on + // cascade — the seek preserves whatever state the child had. The producer + // regression suite (PSNR-checked golden baselines) was produced with this + // un-pause, so skipping it in render mode breaks the entire baseline. + // + // In preview mode (the `` embed used by static sites + // and the Vercel template), un-pausing causes scenes to free-run on GSAP's + // real-time ticker — the master stays paused at 0:00 but each scene + // visibly animates. That's the "autoplay on load" bug. + // + // Keep children paused in preview and un-pause in render. The play/pause + // propagation in `createRuntimePlayer` (see `./player.ts`) then handles + // the preview path: user clicks play, we iterate every registry entry + // and un-pause it; user clicks pause, we pause every registry entry. + const isRenderMode = + typeof window !== "undefined" && + (window as Window & { __HF_VIRTUAL_TIME__?: unknown }).__HF_VIRTUAL_TIME__ != null; + if (isRenderMode && rootChildCandidates.length > 0) { ensureChildCandidatesActive(rootChildCandidates); } if (rootTimeline) { diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index 9a71bf46c..3f2e1b540 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -225,6 +225,42 @@ describe("createRuntimePlayer", () => { expect(() => player.pause()).not.toThrow(); expect(scene.pause).toHaveBeenCalled(); }); + + it("seek intentionally does NOT iterate the registry (cascade handles nested children)", () => { + // Siblings registered in `__timelines` are also added as children of + // the master via `rootTimeline.add(child, startSec)` in `init.ts`. + // GSAP's `totalTime` cascade propagates from the master to each nested + // child at its own nested time automatically. Iterating the registry + // and calling `totalTime(rootTime, false)` on each sibling would + // overwrite that cascade with the wrong absolute time — breaking + // every producer regression baseline. + const master = createMockTimeline({ duration: 20 }); + const scene1 = createMockTimeline({ duration: 5 }); + const scene2 = createMockTimeline({ duration: 15 }); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, scene1, scene2 }), + }); + player.seek(7); + expect(master.pause).toHaveBeenCalledTimes(1); + expect(master.totalTime).toHaveBeenCalledWith(7, false); + expect(scene1.totalTime).not.toHaveBeenCalled(); + expect(scene2.totalTime).not.toHaveBeenCalled(); + }); + + it("renderSeek intentionally does NOT iterate the registry either", () => { + const master = createMockTimeline({ duration: 20 }); + const scene = createMockTimeline({ duration: 10 }); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, scene }), + }); + player.renderSeek(3); + expect(master.totalTime).toHaveBeenCalledWith(3, false); + expect(scene.totalTime).not.toHaveBeenCalled(); + }); }); describe("seek", () => { diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index f18e1b1fa..60ad138df 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -22,8 +22,13 @@ type PlayerDeps = { * Provided so that play/pause propagate to sub-scene timelines registered * alongside the master — e.g. a nested-composition master with per-scene * timelines like `scene1-logo-intro`, `scene2-4-canvas`. Without this, - * pausing the master would leave scene timelines free-running and - * animations would continue to advance visually past the paused time. + * pausing the master would leave scene timelines free-running. + * + * Note: `seek`/`renderSeek` do NOT iterate the registry. Siblings are + * also added as children of the master in `init.ts`, and GSAP's + * `totalTime` cascade positions each child at its nested time + * automatically. Iterating and calling `totalTime(rootTime, false)` on + * each sibling would overwrite the cascade with the wrong absolute time. */ getTimelineRegistry?: () => Record; }; @@ -112,6 +117,19 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { const timeline = deps.getTimeline(); if (!timeline) return; const safeTime = Math.max(0, Number(timeSeconds) || 0); + // Seek only the master. Siblings in the registry are also added as + // children of the master in `init.ts`; GSAP's `totalTime` cascade + // propagates from master to each nested child at its own nested time + // automatically. Iterating the registry and calling + // `totalTime(rootTime, false)` on each sibling would overwrite the + // cascaded nested time with the (wrong) absolute root time — breaking + // every producer golden baseline. + // + // Caveat: in the async-scene-loading path with children individually + // paused (preview mode), cascade won't re-render paused children, so a + // scrub-back after playthrough can leave scenes parked at their + // end state. Fixing that properly requires the async loader to parent + // scenes to the master reliably (see init.ts notes) — follow-up. const quantized = seekTimelineDeterministically(timeline, safeTime, deps.getCanonicalFps()); deps.onDeterministicSeek(quantized); deps.setIsPlaying(false);