Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,8 @@ export function initSandboxRuntimeModular(): void {
setTimeline: (timeline) => {
state.capturedTimeline = timeline;
},
getTimelineRegistry: () =>
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
getIsPlaying: () => state.isPlaying,
setIsPlaying: (playing) => {
state.isPlaying = playing;
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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);
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/runtime/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuntimeTimelineLike | undefined>;
};

function forEachSiblingTimeline(
registry: Record<string, RuntimeTimelineLike | undefined> | 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,
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
16 changes: 13 additions & 3 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
}
Expand Down
112 changes: 112 additions & 0 deletions packages/player/src/shouldInjectRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
38 changes: 38 additions & 0 deletions packages/player/src/shouldInjectRuntime.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading