mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user