mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* 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>
113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|
|
});
|