Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hyperframes-player>` 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) {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/runtime/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuntimeTimelineLike | undefined>;
};
Expand Down Expand Up @@ -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);
Expand Down
Loading