mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(player+core): correctly render and pause nested compositions (#359)
* fix(player): inject runtime immediately for nested compositions 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) <noreply@anthropic.com> * fix(core): propagate play/pause to all sibling timelines 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
733d454d11
commit
e72bcfaed3
@@ -1374,6 +1374,8 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
setTimeline: (timeline) => {
|
setTimeline: (timeline) => {
|
||||||
state.capturedTimeline = timeline;
|
state.capturedTimeline = timeline;
|
||||||
},
|
},
|
||||||
|
getTimelineRegistry: () =>
|
||||||
|
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
|
||||||
getIsPlaying: () => state.isPlaying,
|
getIsPlaying: () => state.isPlaying,
|
||||||
setIsPlaying: (playing) => {
|
setIsPlaying: (playing) => {
|
||||||
state.isPlaying = playing;
|
state.isPlaying = playing;
|
||||||
|
|||||||
@@ -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", () => {
|
describe("seek", () => {
|
||||||
it("does nothing without a timeline", () => {
|
it("does nothing without a timeline", () => {
|
||||||
const deps = createMockDeps(null);
|
const deps = createMockDeps(null);
|
||||||
|
|||||||
@@ -17,8 +17,33 @@ type PlayerDeps = {
|
|||||||
onRenderFrameSeek: (timeSeconds: number) => void;
|
onRenderFrameSeek: (timeSeconds: number) => void;
|
||||||
onShowNativeVideos: () => void;
|
onShowNativeVideos: () => void;
|
||||||
getSafeDuration?: () => number;
|
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(
|
function seekTimelineDeterministically(
|
||||||
timeline: RuntimeTimelineLike,
|
timeline: RuntimeTimelineLike,
|
||||||
timeSeconds: number,
|
timeSeconds: number,
|
||||||
@@ -59,6 +84,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
|||||||
timeline.timeScale(deps.getPlaybackRate());
|
timeline.timeScale(deps.getPlaybackRate());
|
||||||
}
|
}
|
||||||
timeline.play();
|
timeline.play();
|
||||||
|
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
|
||||||
|
if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate());
|
||||||
|
tl.play();
|
||||||
|
});
|
||||||
deps.onDeterministicPlay();
|
deps.onDeterministicPlay();
|
||||||
deps.setIsPlaying(true);
|
deps.setIsPlaying(true);
|
||||||
deps.onShowNativeVideos();
|
deps.onShowNativeVideos();
|
||||||
@@ -68,6 +97,9 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
|||||||
const timeline = deps.getTimeline();
|
const timeline = deps.getTimeline();
|
||||||
if (!timeline) return;
|
if (!timeline) return;
|
||||||
timeline.pause();
|
timeline.pause();
|
||||||
|
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
|
||||||
|
tl.pause();
|
||||||
|
});
|
||||||
const time = Math.max(0, Number(timeline.time()) || 0);
|
const time = Math.max(0, Number(timeline.time()) || 0);
|
||||||
deps.onDeterministicSeek(time);
|
deps.onDeterministicSeek(time);
|
||||||
deps.onDeterministicPause();
|
deps.onDeterministicPause();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js";
|
import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js";
|
||||||
|
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
|
||||||
import { PLAYER_STYLES } from "./styles.js";
|
import { PLAYER_STYLES } from "./styles.js";
|
||||||
|
|
||||||
const DEFAULT_FPS = 30;
|
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)
|
// Check if the runtime bridge is active (__hf or __player from the runtime)
|
||||||
const hasRuntime = !!(win.__hf || win.__player);
|
const hasRuntime = !!(win.__hf || win.__player);
|
||||||
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
|
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
|
||||||
|
const hasNestedCompositions =
|
||||||
|
!!this.iframe.contentDocument?.querySelector("[data-composition-src]");
|
||||||
|
|
||||||
// Auto-inject runtime if GSAP timelines exist but no runtime bridge
|
if (
|
||||||
if (!hasRuntime && hasTimelines && !this._runtimeInjected && attempts >= 5) {
|
shouldInjectRuntime({
|
||||||
|
hasRuntime,
|
||||||
|
hasTimelines,
|
||||||
|
hasNestedCompositions,
|
||||||
|
runtimeInjected: this._runtimeInjected,
|
||||||
|
attempts,
|
||||||
|
})
|
||||||
|
) {
|
||||||
this._injectRuntime();
|
this._injectRuntime();
|
||||||
return; // Wait for runtime to load and initialize
|
return; // Wait for runtime to load and initialize
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user