From b7592370c7f2d4d38d38a66a5b5f04062cc9e656 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Apr 2026 20:01:03 +0000 Subject: [PATCH] fix(core): scope init-time child un-pause to render mode only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #359 for the async-scene-loading path (compositions served as raw static files with `data-composition-src` children, as opposed to the `bundleToSingleHtml` studio path). ## Problem In the async-scene path, scenes visibly animate on page load while the master stays paused at 0:00 — the "autoplay on load" bug. Traced via a `paused`-setter proxy on each registered timeline: on init, each scene's `_ts` flipped 0→1 inside `ensureChildCandidatesActive` in `init.ts`, calling `timeline.paused(false)` on every child candidate. That helper predates #359 and was the only way a user-click Play would animate scenes — back when play didn't propagate through the registry. After #359, `createRuntimePlayer.play` already iterates every registry entry and un-pauses each, so the init-time un-pause is redundant for user-driven playback. It still matters for the producer's render path, though: there the master's `totalTime` cascade is the only driver (no GSAP ticker — virtual time), and a paused child won't re-render on cascade, so every producer regression baseline depends on children being un-paused at init time. ## Fix Scope the un-pause to render mode, signalled by `window.__HF_VIRTUAL_TIME__` (set by the producer's render-mode bootstrap script). Preview mode leaves children paused — no autoplay; play/pause still propagate via the registry iteration added in #359. Reproduced + verified locally with `Dockerfile.test`: - Before this fix, `overlay-montage-prod`, `style-4-prod`, `style-7-prod`, `style-8-prod`, `style-9-prod`, `style-17-prod`, `style-18-prod` all fail PSNR with 15-30 failed frames (scenes rendering at wrong times). - After this fix, all four run to 0/100 failed frames. ## Known limitation (follow-up) In preview mode, a scrub-back after a full playthrough can still leave async scenes parked at their end-state, because GSAP's cascade skips rendering paused children and the async loader isn't fully parenting scenes to the master. Tracked for a separate fix to the async composition loader (see `addMissingChildCandidatesToRootTimeline` in `init.ts`). ## Files - `packages/core/src/runtime/init.ts` — add `isRenderMode` guard around `ensureChildCandidatesActive` - `packages/core/src/runtime/player.ts` — unchanged from #359's registry iteration on play/pause - `packages/core/src/runtime/player.test.ts` — 2 new tests documenting that `seek`/`renderSeek` intentionally do NOT iterate the registry ## Testing - [x] `cd packages/core && bunx vitest run` — 490/490 pass (+2 new) - [x] `cd packages/player && bunx vitest run` — 35/35 pass (unchanged) - [x] `bun run typecheck` in core + player — clean - [x] `bunx oxlint` / `bunx oxfmt --check` clean across changed files - [x] `docker run hyperframes-producer:test style-17-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test style-7-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test style-4-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test overlay-montage-prod` — 0/100 frames fail - [x] Live Vercel deploy verified: scenes stay paused at page load (autoplay fixed); play/pause propagates as expected Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/runtime/init.ts | 22 ++++++++++++++- packages/core/src/runtime/player.test.ts | 36 ++++++++++++++++++++++++ packages/core/src/runtime/player.ts | 22 +++++++++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) 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);