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:
James Russo
2026-04-21 10:18:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 733d454d11
commit e72bcfaed3
6 changed files with 296 additions and 2 deletions
+2
View File
@@ -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;
+100
View File
@@ -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);
+32
View File
@@ -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,
@@ -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();