From 32296245edf479f3f60efe4d8b3ac169592b2c14 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Apr 2026 04:27:40 +0000 Subject: [PATCH 1/2] fix(player): inject runtime immediately for nested compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compositions that use `data-composition-src` on child elements require the HyperFrames runtime to load those scenes — there is no way for the iframe to render without it. The existing probe loop delayed runtime injection behind a 5-tick attempts gate so the adapter path could try to resolve a timeline first. For nested compositions that race lost: a composition like the `product-promo` registry example registers an inline pre-runtime GSAP timeline at `window.__timelines["main"]` (covering only a partial duration, e.g. 14s of a 20s master) while the iframe document loads. The probe's adapter check finds that timeline and locks the player into a "ready" state against it — which short-circuits the attempts gate and the runtime never gets injected. The iframe ends up blank because the runtime is what would have loaded the child scenes via `data-composition-src`. This change splits the injection decision into a pure helper, `shouldInjectRuntime(state)`, and treats nested compositions as "inject immediately, skip the gate." Self-contained GSAP-only compositions retain the 5-tick grace period so the adapter path keeps first shot for them. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/player/src/hyperframes-player.ts | 16 ++- .../player/src/shouldInjectRuntime.test.ts | 112 ++++++++++++++++++ packages/player/src/shouldInjectRuntime.ts | 38 ++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 packages/player/src/shouldInjectRuntime.test.ts create mode 100644 packages/player/src/shouldInjectRuntime.ts diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index b9503fe20..2f0314afc 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -1,4 +1,5 @@ import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js"; +import { shouldInjectRuntime } from "./shouldInjectRuntime.js"; import { PLAYER_STYLES } from "./styles.js"; const DEFAULT_FPS = 30; @@ -422,9 +423,18 @@ class HyperframesPlayer extends HTMLElement { // Check if the runtime bridge is active (__hf or __player from the runtime) const hasRuntime = !!(win.__hf || win.__player); const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0); - - // Auto-inject runtime if GSAP timelines exist but no runtime bridge - if (!hasRuntime && hasTimelines && !this._runtimeInjected && attempts >= 5) { + const hasNestedCompositions = + !!this.iframe.contentDocument?.querySelector("[data-composition-src]"); + + if ( + shouldInjectRuntime({ + hasRuntime, + hasTimelines, + hasNestedCompositions, + runtimeInjected: this._runtimeInjected, + attempts, + }) + ) { this._injectRuntime(); return; // Wait for runtime to load and initialize } diff --git a/packages/player/src/shouldInjectRuntime.test.ts b/packages/player/src/shouldInjectRuntime.test.ts new file mode 100644 index 000000000..488c7b685 --- /dev/null +++ b/packages/player/src/shouldInjectRuntime.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { shouldInjectRuntime, type ProbeState } from "./shouldInjectRuntime.js"; + +const baseState: ProbeState = { + hasRuntime: false, + hasTimelines: false, + hasNestedCompositions: false, + runtimeInjected: false, + attempts: 1, +}; + +describe("shouldInjectRuntime", () => { + it("never injects when the runtime bridge is already present", () => { + for (let attempts = 0; attempts <= 40; attempts++) { + expect( + shouldInjectRuntime({ + ...baseState, + hasRuntime: true, + hasTimelines: true, + hasNestedCompositions: true, + attempts, + }), + ).toBe(false); + } + }); + + it("never injects twice — runtimeInjected short-circuits", () => { + expect( + shouldInjectRuntime({ + ...baseState, + hasTimelines: true, + hasNestedCompositions: true, + runtimeInjected: true, + attempts: 10, + }), + ).toBe(false); + }); + + describe("nested compositions (data-composition-src children)", () => { + it("injects on the first tick — no attempts gate", () => { + expect( + shouldInjectRuntime({ + ...baseState, + hasNestedCompositions: true, + attempts: 1, + }), + ).toBe(true); + }); + + // Regression: product-promo and other registry examples register inline + // pre-runtime timelines (`window.__timelines["main"]`) with only partial + // durations during iframe load. Without this, the adapter path would + // resolve against that partial timeline and lock the player into a + // broken "ready" state before the 5-tick fallback ever fires. + it("injects even when pre-runtime timelines are already registered", () => { + expect( + shouldInjectRuntime({ + ...baseState, + hasTimelines: true, + hasNestedCompositions: true, + attempts: 1, + }), + ).toBe(true); + }); + + it("does not re-inject once runtimeInjected flips", () => { + expect( + shouldInjectRuntime({ + ...baseState, + hasNestedCompositions: true, + runtimeInjected: true, + attempts: 1, + }), + ).toBe(false); + }); + }); + + describe("self-contained compositions (GSAP-only, no nested children)", () => { + it("waits during the grace period (attempts < 5) even with timelines", () => { + for (let attempts = 0; attempts < 5; attempts++) { + expect( + shouldInjectRuntime({ + ...baseState, + hasTimelines: true, + attempts, + }), + ).toBe(false); + } + }); + + it("injects as a fallback at attempt 5", () => { + expect( + shouldInjectRuntime({ + ...baseState, + hasTimelines: true, + attempts: 5, + }), + ).toBe(true); + }); + + it("does not inject when there are neither timelines nor nested scenes", () => { + for (let attempts = 0; attempts <= 40; attempts++) { + expect( + shouldInjectRuntime({ + ...baseState, + attempts, + }), + ).toBe(false); + } + }); + }); +}); diff --git a/packages/player/src/shouldInjectRuntime.ts b/packages/player/src/shouldInjectRuntime.ts new file mode 100644 index 000000000..f409f519e --- /dev/null +++ b/packages/player/src/shouldInjectRuntime.ts @@ -0,0 +1,38 @@ +/** + * Decide whether the player should inject the HyperFrames runtime on the + * current probe tick. + * + * The player polls the loaded iframe every 200ms to discover either: + * - a runtime bridge already installed (`window.__hf` / `window.__player`), or + * - GSAP timelines registered at `window.__timelines`. + * + * Two classes of composition require different injection timing: + * + * Nested — the composition uses `data-composition-src` on child elements to + * lazy-load sub-scenes. The runtime is what loads those children, so the + * composition cannot possibly render on its own. We inject immediately; if + * we waited, an inline pre-runtime `gsap.timeline` (common for authoring a + * preview before the runtime rebuilds the master timeline) would register + * at `__timelines["main"]` with a partial duration, and the adapter path + * would then lock the player into `ready` against that incomplete timeline. + * + * Self-contained — the composition has no nested scenes and ships all of + * its animation inline (timelines registered under `__timelines`). These + * don't strictly need the runtime; the adapter can drive them directly. + * We give the adapter path first shot (a 5-tick grace period) and only + * inject the runtime as a fallback if no adapter emerges. + */ +export interface ProbeState { + hasRuntime: boolean; + hasTimelines: boolean; + hasNestedCompositions: boolean; + runtimeInjected: boolean; + attempts: number; +} + +export function shouldInjectRuntime(state: ProbeState): boolean { + if (state.hasRuntime || state.runtimeInjected) return false; + if (state.hasNestedCompositions) return true; + if (state.hasTimelines && state.attempts >= 5) return true; + return false; +} From 393da689e8d626326e8250a1e4a25a83134658a3 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Apr 2026 04:46:48 +0000 Subject: [PATCH 2/2] fix(core): propagate play/pause to all sibling timelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pausing or playing the master timeline only called `.pause()` / `.play()` on `state.capturedTimeline` — the single adapter-selected timeline. In a nested composition (a master with `data-composition-src` children), each scene's own timeline is registered as a sibling in `window.__timelines`, so they would keep advancing after the user clicked pause. The player UI froze at the paused time while the visual content continued to animate, eventually finishing all scene-level animations and landing on an empty end-state. Wire `window.__timelines` into the runtime player via a new `getTimelineRegistry` dep, iterate the registry on play/pause, and forward `timeScale` to siblings when play() starts so a changed playback-rate applies uniformly. Covered by 7 new unit tests in player.test.ts, including the identity- equality check (don't double-invoke the master), playbackRate propagation, a broken-sibling swallow, and a back-compat case with no registry supplied. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/runtime/init.ts | 2 + packages/core/src/runtime/player.test.ts | 100 +++++++++++++++++++++++ packages/core/src/runtime/player.ts | 32 ++++++++ 3 files changed, 134 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7ac403d0f..fd5a9c437 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1374,6 +1374,8 @@ export function initSandboxRuntimeModular(): void { setTimeline: (timeline) => { state.capturedTimeline = timeline; }, + getTimelineRegistry: () => + (window.__timelines ?? {}) as Record, getIsPlaying: () => state.isPlaying, setIsPlaying: (playing) => { state.isPlaying = playing; diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index cc071c3d5..9a71bf46c 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -127,6 +127,106 @@ describe("createRuntimePlayer", () => { }); }); + // Regression: nested compositions register sibling timelines alongside + // the master (e.g. `scene1-logo-intro` + `scene2-4-canvas` next to the + // master's own inline timeline). Before this, pausing the master would + // leave siblings free-running, so scene animations kept advancing and the + // composition would visibly drift past the paused time even though the + // player UI was frozen. + describe("timeline registry propagation", () => { + it("pauses every sibling timeline, not just the master", () => { + const master = createMockTimeline({ time: 5 }); + const scene1 = createMockTimeline(); + const scene2 = createMockTimeline(); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, scene1, scene2 }), + }); + player.pause(); + expect(master.pause).toHaveBeenCalledTimes(1); + expect(scene1.pause).toHaveBeenCalledTimes(1); + expect(scene2.pause).toHaveBeenCalledTimes(1); + }); + + it("plays every sibling timeline when the master plays", () => { + const master = createMockTimeline({ time: 0, duration: 10 }); + const scene1 = createMockTimeline(); + const scene2 = createMockTimeline(); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, scene1, scene2 }), + }); + player.play(); + expect(master.play).toHaveBeenCalledTimes(1); + expect(scene1.play).toHaveBeenCalledTimes(1); + expect(scene2.play).toHaveBeenCalledTimes(1); + }); + + it("propagates playbackRate to siblings on play", () => { + const master = createMockTimeline({ time: 0, duration: 10 }); + const scene1 = createMockTimeline(); + const deps = createMockDeps(master); + deps.getPlaybackRate.mockReturnValue(2); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, scene1 }), + }); + player.play(); + expect(scene1.timeScale).toHaveBeenCalledWith(2); + }); + + it("does not call pause/play on the master twice through the registry", () => { + const master = createMockTimeline({ time: 5 }); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + // The master is identity-equal to one of the registry entries. + getTimelineRegistry: () => ({ main: master }), + }); + player.pause(); + expect(master.pause).toHaveBeenCalledTimes(1); + }); + + it("swallows errors from a broken sibling without breaking pause", () => { + const master = createMockTimeline({ time: 5 }); + const broken = createMockTimeline(); + (broken.pause as ReturnType).mockImplementationOnce(() => { + throw new Error("boom"); + }); + const ok = createMockTimeline(); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, broken, ok }), + }); + expect(() => player.pause()).not.toThrow(); + expect(master.pause).toHaveBeenCalled(); + expect(ok.pause).toHaveBeenCalled(); + }); + + it("is a no-op when no registry is supplied (back-compat)", () => { + const master = createMockTimeline({ time: 5 }); + const deps = createMockDeps(master); + const player = createRuntimePlayer(deps); + expect(() => player.pause()).not.toThrow(); + expect(master.pause).toHaveBeenCalled(); + }); + + it("tolerates undefined entries in the registry", () => { + const master = createMockTimeline({ time: 5 }); + const scene = createMockTimeline(); + const deps = createMockDeps(master); + const player = createRuntimePlayer({ + ...deps, + getTimelineRegistry: () => ({ main: master, gone: undefined, scene }), + }); + expect(() => player.pause()).not.toThrow(); + expect(scene.pause).toHaveBeenCalled(); + }); + }); + describe("seek", () => { it("does nothing without a timeline", () => { const deps = createMockDeps(null); diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index a002fbe36..f18e1b1fa 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -17,8 +17,33 @@ type PlayerDeps = { onRenderFrameSeek: (timeSeconds: number) => void; onShowNativeVideos: () => void; getSafeDuration?: () => number; + /** + * Optional registry of sibling timelines (typically `window.__timelines`). + * 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. + */ + getTimelineRegistry?: () => Record; }; +function forEachSiblingTimeline( + registry: Record | undefined | null, + master: RuntimeTimelineLike, + fn: (tl: RuntimeTimelineLike) => void, +): void { + if (!registry) return; + for (const tl of Object.values(registry)) { + if (!tl || tl === master) continue; + try { + fn(tl); + } catch { + // ignore sibling failures — one broken timeline shouldn't poison play/pause + } + } +} + function seekTimelineDeterministically( timeline: RuntimeTimelineLike, timeSeconds: number, @@ -59,6 +84,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { timeline.timeScale(deps.getPlaybackRate()); } timeline.play(); + forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { + if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate()); + tl.play(); + }); deps.onDeterministicPlay(); deps.setIsPlaying(true); deps.onShowNativeVideos(); @@ -68,6 +97,9 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { const timeline = deps.getTimeline(); if (!timeline) return; timeline.pause(); + forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { + tl.pause(); + }); const time = Math.max(0, Number(timeline.time()) || 0); deps.onDeterministicSeek(time); deps.onDeterministicPause();