Files
hyperframes/packages/core/src/runtime/init.test.ts
T
Vance Ingalls 952401d12a feat(audio): ship the audio FX, group and mute features to everyone
The three audio canaries (`audio-fx-rack`, `audio-track-mute`,
`audio-groups`) sat at 0% while the stack was in review. Open them to
100% by deleting them rather than raising the percentage — a canary that
gates nothing is a branch every future reader has to evaluate.

Removed:
- the three `CANARIES` registry entries;
- every `isCanaryEnabled` branch in the studio (FX button, group
  pointer, rack section, track-mute affordances) — the features now
  render on their own preconditions;
- the runtime's `canaries` record and its `__hf.setCanaries` handler,
  plus the `setCanaries` type surface;
- `syncRuntimeMedia`'s `silenceHiddenAudio` option. Its only caller
  always passed `true`, so hidden audio is now unconditionally silent in
  preview, matching what `audioMixer` already renders.

Tests assert the unconditional behaviour instead of the enrolment
transition: the FX button is present on any audio track and absent on a
visual one, the group pointer follows clip count rather than enrolment,
and the hidden-clip zero is paired with a visible-clip control so the
assertion can still fail.
2026-08-20 17:28:43 -07:00

3057 lines
113 KiB
TypeScript

// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { initSandboxRuntimeModular } from "./init";
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
import { WebAudioTransport } from "./webAudioTransport";
import type { RuntimeTimelineLike } from "./types";
it("schedules WebAudio element gain from author volume without bridge volume", () => {
const source = readFileSync("src/runtime/init.ts", "utf8");
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
});
function createMockTimeline(duration: number): RuntimeTimelineLike {
const state = { time: 0, paused: true, duration };
return {
play: () => {
state.paused = false;
},
pause: () => {
state.paused = true;
},
seek: (time?: number) => {
if (time !== undefined) state.time = time;
return state.time;
},
totalTime: (time?: number) => {
if (time !== undefined) state.time = time;
return state.time;
},
time: () => state.time,
duration: () => state.duration,
add: () => {},
paused: (value?: boolean) => {
if (typeof value === "boolean") {
state.paused = value;
}
return state.paused;
},
timeScale: () => {},
set: () => {},
getChildren: () => [],
};
}
function createPaddableMockTimeline(duration: number): RuntimeTimelineLike {
const timeline = createMockTimeline(duration) as RuntimeTimelineLike & {
to: (_target: object, vars: { duration: number }, position?: number) => void;
};
const baseDuration = timeline.duration;
let paddedDuration = baseDuration();
timeline.duration = () => paddedDuration;
// Mirrors GSAP: an omitted position appends sequentially at the current end.
timeline.to = (_target, vars, position) => {
const resolvedPosition = position ?? paddedDuration;
paddedDuration = Math.max(
paddedDuration,
resolvedPosition + Math.max(0, Number(vars.duration) || 0),
);
};
return timeline;
}
function createManualRaf() {
let now = 0;
let nextId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
return {
requestAnimationFrame: (callback: FrameRequestCallback) => {
nextId += 1;
callbacks.set(nextId, callback);
return nextId;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
step: (milliseconds: number) => {
now += milliseconds;
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) {
callback(now);
}
},
now: () => now,
};
}
function withStudioIframe(run: () => void): void {
const originalParent = window.parent;
Object.defineProperty(window, "parent", {
configurable: true,
value: {},
});
try {
run();
} finally {
Object.defineProperty(window, "parent", {
configurable: true,
value: originalParent,
});
}
}
describe("initSandboxRuntimeModular", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
beforeEach(() => {
document.body.innerHTML = "";
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
globalThis.CSS.escape ??= (value: string) => value;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
});
it.each([
["2x", 5],
["0x2", 10],
])("derives a native-parsed natural media window for rate %s", (rate, expected) => {
document.body.innerHTML = `<div data-composition-id="main" data-root="true"><video data-start="0" data-playback-rate="${rate}"></video></div>`;
const video = document.querySelector("video")!;
Object.defineProperty(video, "duration", { value: 10, configurable: true });
window.__timelines = {};
initSandboxRuntimeModular();
expect(window.__player?.getDuration()).toBe(expected);
});
it.each([10, 11])(
"preserves a known zero natural media window at source EOF (start=%s)",
(start) => {
document.body.innerHTML = `<div data-composition-id="main" data-root="true"><video data-start="0" data-media-start="${start}"></video></div>`;
const video = document.querySelector("video")!;
Object.defineProperty(video, "duration", { value: 10, configurable: true });
window.__timelines = {};
initSandboxRuntimeModular();
expect(window.__player?.getDuration()).toBe(0);
},
);
it("keeps a boosted clip legal on the element when the bridge sets volume", () => {
// `data-volume` may hold up to 12 dB of authored gain. `el.volume` is
// spec-pinned to [0,1] and THROWS outside it, so assigning the product raw
// aborted the loop — every element after the boosted one kept its old
// volume, and the bridge's own state said otherwise.
document.body.innerHTML =
`<div data-composition-id="main" data-root="true">` +
`<audio data-start="0" data-volume="3.98"></audio>` +
`<audio data-start="0" data-volume="0.5"></audio>` +
`</div>`;
window.__timelines = {};
initSandboxRuntimeModular();
const [boosted, quiet] = Array.from(document.querySelectorAll("audio"));
if (!boosted || !quiet) throw new Error("expected both clips");
// Sentinels, so the assertions cannot be satisfied by what the runtime
// already applied while starting up.
boosted.volume = 0.2;
quiet.volume = 0.1;
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
}),
);
expect(boosted.volume).toBe(1);
// The clip after the boosted one is what a throw mid-loop strands.
expect(quiet.volume).toBeCloseTo(0.5, 5);
});
afterEach(() => {
window.__hfRuntimeTeardown?.();
document.body.innerHTML = "";
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
delete window.__player;
delete window.__playerReady;
delete window.__renderReady;
delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG;
delete window.__hfTimelinesBuilding;
delete (window as { THREE?: unknown }).THREE;
delete (window as { __hfAutoNoopRegistered?: boolean }).__hfAutoNoopRegistered;
delete window.gsap;
vi.restoreAllMocks();
vi.useRealTimers();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
/**
* `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
* `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
* argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
* clip authored above unity threw
* `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
* (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
* every media element after it kept its old volume too.
*/
it("clamps the native volume of an over-unity clip instead of throwing", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const loud = document.createElement("audio");
loud.setAttribute("data-start", "0");
loud.setAttribute("data-duration", "10");
loud.setAttribute("data-volume", "2.42103");
loud.load = () => {};
root.appendChild(loud);
// Second element proves the throw took the whole sweep down with it, not just
// the offending clip.
const quiet = document.createElement("audio");
quiet.setAttribute("data-start", "0");
quiet.setAttribute("data-duration", "10");
quiet.setAttribute("data-volume", "0.5");
quiet.load = () => {};
root.appendChild(quiet);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const errors: string[] = [];
const onError = (e: ErrorEvent) => errors.push(String(e.message ?? e.error));
window.addEventListener("error", onError);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
}),
);
window.removeEventListener("error", onError);
expect(errors).toEqual([]);
});
/**
* The runtime stamps `data-start`/`data-duration` on every id'd child of the
* composition root so a blank canvas still shows selectable rows. An
* `<hf-audio-group>` is a mixer BUS, not a clip: stamping it put it in
* `__clipManifest` as a full-duration element, which the studio drew as an
* ordinary clip row above the real group header — draggable, trimmable, and
* deletable, and deleting it takes the bus (so the group's FX rack) with it.
*/
it("does not stamp timing onto an <hf-audio-group> bus", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const bus = document.createElement("hf-audio-group");
bus.id = "voiceover";
bus.setAttribute("data-label", "Voiceover");
root.appendChild(bus);
// A plain id'd sibling proves the stamp still happens for everything else.
const caption = document.createElement("div");
caption.id = "cap-1";
root.appendChild(caption);
window.__timelines = { main: createMockTimeline(10) };
// The stamp only runs inside the studio preview (`window.parent !== window`),
// which jsdom is not — so the condition has to be staged for the test.
const realParent = window.parent;
Object.defineProperty(window, "parent", { value: {}, configurable: true });
try {
initSandboxRuntimeModular();
} finally {
Object.defineProperty(window, "parent", { value: realParent, configurable: true });
}
expect(bus.hasAttribute("data-start")).toBe(false);
expect(bus.hasAttribute("data-duration")).toBe(false);
expect(caption.getAttribute("data-start")).toBe("0");
});
it("resolves Studio hold as a deterministic step at the segment end", () => {
const defaultEase = (progress: number) => progress;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const first = window.gsap.parseEase?.("hold");
const second = window.gsap.parseEase?.("hold");
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function") return;
expect([0, 0.25, 0.5, 0.99].map(first)).toEqual([0, 0, 0, 0]);
expect(first(1)).toBe(1);
expect(first(1.01)).toBe(1);
expect(originalParseEase).not.toHaveBeenCalledWith("hold");
});
it("repairs a keyframes tween's inner-timeline ease baked to undefined before custom-ease registration", () => {
// The composition inline script builds keyframes tweens BEFORE this runtime
// registers the custom eases, so a `{keyframes, ease:"hold"}` tween's inner
// timeline `_ease` bakes to undefined (GSAP resolves it once at build via the
// internal ease map). GSAP then throws "_ease is not a function" on the first
// render. The runtime must re-resolve that inner ease after registration.
window.gsap = {
timeline: () => createMockTimeline(20),
parseEase: vi.fn(() => (progress: number) => progress),
registerPlugin: vi.fn(),
registerEase: vi.fn(),
} as unknown as typeof window.gsap;
const innerTimeline: { _ease?: unknown } = { _ease: undefined };
const keyframesTween = {
vars: { ease: "hold", keyframes: { "0%": { x: 0 }, "100%": { x: 50 } } },
timeline: innerTimeline,
_ease: (progress: number) => progress,
targets: () => [document.createElement("div")],
};
const main = createMockTimeline(20);
main.getChildren = () => [keyframesTween as unknown as RuntimeTimelineLike];
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main };
expect(innerTimeline._ease).toBeUndefined();
initSandboxRuntimeModular();
// Bind re-resolves the inner ease to a real function (the installed hold ease),
// so a subsequent render can call `timeline._ease(...)` without throwing.
expect(innerTimeline._ease).toBeTypeOf("function");
});
it("isolates a failed keyframe ease repair and reports it without skipping siblings", () => {
const outbound: Array<{ type?: string; event?: string }> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message === "object" && message !== null) {
outbound.push(message as { type?: string; event?: string });
}
});
window.gsap = {
timeline: () => createMockTimeline(20),
parseEase: vi.fn((ease: unknown) => {
if (ease === "bad-ease") throw new Error("bad ease");
return (progress: number) => progress;
}),
registerPlugin: vi.fn(),
registerEase: vi.fn(),
} as unknown as typeof window.gsap;
const failedInner: { _ease?: unknown } = { _ease: undefined };
const repairedInner: { _ease?: unknown } = { _ease: undefined };
const main = createMockTimeline(20);
main.getChildren = () =>
[
{ vars: { ease: "bad-ease", keyframes: {} }, timeline: failedInner },
{ vars: { ease: "hold", keyframes: {} }, timeline: repairedInner },
] as unknown as RuntimeTimelineLike[];
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
window.__timelines = { main };
initSandboxRuntimeModular();
expect(failedInner._ease).toBeUndefined();
expect(repairedInner._ease).toBeTypeOf("function");
expect(outbound).toContainEqual(
expect.objectContaining({
type: "analytics",
event: "keyframe_ease_repair_failed",
}),
);
});
it("resolves Studio custom cubic-bezier eases on the composition GSAP instance", () => {
const defaultEase = (progress: number) => 1 - (1 - progress) ** 2;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const custom = "custom(M0,0 C0.42,0 0.58,1 1,1)";
const first = window.gsap.parseEase?.(custom);
const second = window.gsap.parseEase?.(custom);
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function" || typeof second !== "function") return;
const progressSamples = [0, 0.25, 0.5, 0.75, 1];
expect(progressSamples.map(first)).toEqual(progressSamples.map(second));
expect(first(0.25)).toBeCloseTo(0.1292, 4);
expect(first(0.5)).toBeCloseTo(0.5, 6);
expect(first(0.5)).not.toBeCloseTo(defaultEase(0.5), 6);
expect(first(0.75)).toBeCloseTo(0.8708, 4);
expect(originalParseEase).not.toHaveBeenCalledWith(custom);
expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("power1.out");
expect(window.gsap.parseEase?.("custom(not-a-path)")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("custom(not-a-path)");
const installedParseEase = window.gsap.parseEase;
initSandboxRuntimeModular();
expect(window.gsap.parseEase).toBe(installedParseEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: vi.fn(() => defaultEase),
registerPlugin: vi.fn(),
};
initSandboxRuntimeModular();
const freshResolution = window.gsap.parseEase?.(custom);
expect(freshResolution).toBeTypeOf("function");
if (typeof freshResolution === "function") {
expect(progressSamples.map(freshResolution)).toEqual(progressSamples.map(first));
}
});
it("resolves Studio spring eases as deterministic oscillations that settle exactly", () => {
const defaultEase = (progress: number) => progress;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const first = window.gsap.parseEase?.("spring(0.5)");
const second = window.gsap.parseEase?.("spring(0.5)");
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function" || typeof second !== "function") return;
const samples = Array.from({ length: 101 }, (_, index) => first(index / 100));
expect(first(0)).toBe(0);
expect(first(1)).toBe(1);
expect(Math.max(...samples)).toBeGreaterThan(1);
expect(samples).toEqual(Array.from({ length: 101 }, (_, index) => second(index / 100)));
expect(originalParseEase).not.toHaveBeenCalledWith("spring(0.5)");
expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("power1.out");
});
it("keeps authored composition hosts visible when the live child timeline is shorter", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-1");
child.setAttribute("data-start", "0");
child.setAttribute("data-hf-authored-duration", "14");
root.appendChild(child);
window.__timelines = {
main: createMockTimeline(20),
"slide-1": createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.renderSeek(9);
expect(child.style.visibility).toBe("visible");
});
it("keeps WebGPU presentation active after renderSeek pauses the frame", async () => {
vi.useFakeTimers();
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-requires-webgpu", "");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(10) };
const times: number[] = [];
const onSeek = (event: Event) => {
times.push((event as CustomEvent<{ time: number }>).detail.time);
};
window.addEventListener("hf-seek", onSeek);
initSandboxRuntimeModular();
window.__player?.renderSeek(4);
await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS);
window.removeEventListener("hf-seek", onSeek);
expect(times).toEqual([0, 4, 4]);
});
it("uses export render fps when quantizing renderSeek", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const timeline = createMockTimeline(1);
window.__timelines = { main: timeline };
(
window as {
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: "render-options" };
}
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
fps: 60,
fpsSource: "render-options",
};
initSandboxRuntimeModular();
window.__player?.renderSeek(1 / 60);
expect(timeline.time()).toBeCloseTo(1 / 60, 6);
expect(infoSpy).toHaveBeenCalledWith(
"[hyperframes] render runtime fps",
expect.objectContaining({
canonicalFps: 60,
source: "render-options",
rawFpsSource: "render-options",
rawFps: 60,
}),
);
});
it("surfaces unknown export render fps sources without collapsing them to render-options", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
(
window as {
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: string };
}
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
fps: 60,
fpsSource: "future-source",
};
initSandboxRuntimeModular();
expect(infoSpy).toHaveBeenCalledWith(
"[hyperframes] render runtime fps",
expect.objectContaining({
canonicalFps: 60,
source: "unknown",
rawFpsSource: "future-source",
}),
);
});
it("keeps the default 30fps renderSeek grid when export render fps is absent", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const timeline = createMockTimeline(1);
window.__timelines = { main: timeline };
initSandboxRuntimeModular();
// This is the originally broken 60fps render sample under the historical
// 30fps runtime default: floor((1 / 60) * 30) / 30 = 0.
window.__player?.renderSeek(1 / 60);
expect(timeline.time()).toBe(0);
});
it("uses live child timeline duration when a composition host has no authored duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-1");
child.setAttribute("data-start", "0");
root.appendChild(child);
window.__timelines = {
main: createMockTimeline(20),
"slide-1": createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.renderSeek(7);
expect(child.style.visibility).toBe("visible");
player?.renderSeek(9);
expect(child.style.visibility).toBe("hidden");
});
it("binds the sole registered timeline even when the root id is missing", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Root is MISSING data-composition-id, but there is exactly one usable
// timeline registered. The DX fallback can bind it unambiguously instead of
// letting the render freeze at t=0.
const root = document.createElement("div");
root.className = "clip";
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(6) };
initSandboxRuntimeModular();
const warned = warnSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(window.__player?.getDuration()).toBe(6);
expect(warned).not.toContain("Root timeline not bound");
});
it("uses the shorter authored host window when the child timeline is longer", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-1");
child.setAttribute("data-start", "0");
child.setAttribute("data-hf-authored-duration", "2");
root.appendChild(child);
window.__timelines = {
main: createMockTimeline(20),
"slide-1": createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.renderSeek(3);
expect(child.style.visibility).toBe("hidden");
});
it("uses a half-open interval around a timed element's end boundary", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "2.5");
root.appendChild(clip);
window.__timelines = { main: createMockTimeline(5) };
initSandboxRuntimeModular();
window.__player?.renderSeek(2.5 - 1e-9);
expect(clip.style.visibility).toBe("visible");
window.__player?.renderSeek(2.5);
expect(clip.style.visibility).toBe("hidden");
window.__player?.renderSeek(2.5 + 1e-9);
expect(clip.style.visibility).toBe("hidden");
});
it("keeps external composition hosts visible through their authored duration", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "sub");
child.setAttribute("data-composition-src", "compositions/sub.html");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "3");
root.appendChild(child);
const template = document.createElement("template");
template.id = "sub-template";
template.innerHTML = `
<div data-composition-id="sub" data-width="1920" data-height="1080">
<div id="hold-marker">HOLD ME</div>
</div>
`;
document.body.appendChild(template);
window.__timelines = {
main: createMockTimeline(3),
sub: createMockTimeline(1),
};
initSandboxRuntimeModular();
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const player = window.__player;
expect(player).toBeDefined();
expect(child.querySelector("#hold-marker")?.textContent).toBe("HOLD ME");
player?.renderSeek(2);
expect(child.style.visibility).toBe("visible");
});
it("removes external composition head links during runtime teardown", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "sub");
child.setAttribute("data-composition-src", "https://example.com/compositions/sub.html");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "3");
root.appendChild(child);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
`<html><head><link rel="stylesheet" href="./sub.css"></head><body><template id="sub-template"><div data-composition-id="sub">Sub</div></template></body></html>`,
{ status: 200 },
),
);
window.__timelines = { main: createMockTimeline(3), sub: createMockTimeline(3) };
initSandboxRuntimeModular();
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const injectedLink = document.head.querySelector<HTMLLinkElement>(
'link[href="https://example.com/compositions/sub.css"]',
);
expect(injectedLink).not.toBeNull();
window.__hfRuntimeTeardown?.();
expect(injectedLink?.isConnected).toBe(false);
});
it("keeps compiled external composition hosts visible through their authored duration", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "sub");
child.setAttribute("data-composition-file", "compositions/sub.html");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "3");
child.innerHTML = '<div id="hold-marker">HOLD ME</div>';
root.appendChild(child);
window.__timelines = {
main: createMockTimeline(3),
sub: createMockTimeline(1),
};
initSandboxRuntimeModular();
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const player = window.__player;
expect(player).toBeDefined();
player?.renderSeek(2);
expect(child.style.visibility).toBe("visible");
});
it("pads the root timeline to the authored composition schedule before seeking visibility", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const slide1 = document.createElement("div");
slide1.id = "slide-1";
slide1.setAttribute("data-composition-id", "slide-1");
slide1.setAttribute("data-start", "0");
slide1.setAttribute("data-hf-authored-duration", "14");
root.appendChild(slide1);
const slide2 = document.createElement("div");
slide2.id = "slide-2";
slide2.setAttribute("data-composition-id", "slide-2");
slide2.setAttribute("data-start", "slide-1");
slide2.setAttribute("data-hf-authored-duration", "12");
root.appendChild(slide2);
const slide3 = document.createElement("div");
slide3.id = "slide-3";
slide3.setAttribute("data-composition-id", "slide-3");
slide3.setAttribute("data-start", "slide-2");
slide3.setAttribute("data-hf-authored-duration", "16");
root.appendChild(slide3);
window.__timelines = {
main: createPaddableMockTimeline(14),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
expect(player?.getDuration()).toBe(42);
player?.seek(30);
expect(root.style.visibility).toBe("visible");
expect(slide1.style.visibility).toBe("hidden");
expect(slide2.style.visibility).toBe("hidden");
expect(slide3.style.visibility).toBe("visible");
});
it("extends the playable duration to the root's declared data-duration when the timeline ends short", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "250.5");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
// GSAP timeline ends 0.1s short of the declared duration — the declared
// data-duration must win, or duration-gated consumers (studio adapter
// selection) reject the runtime player and audio is silently lost.
window.__timelines = {
main: createMockTimeline(250.4),
};
initSandboxRuntimeModular();
expect(window.__player?.getDuration()).toBe(250.5);
});
it("keeps the timeline duration when it exceeds the root's declared data-duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = {
main: createMockTimeline(12),
};
initSandboxRuntimeModular();
expect(window.__player?.getDuration()).toBe(12);
});
// #6: a single timeline registered under a key that does NOT match the root's
// data-composition-id must still bind (sole-timeline fallback) instead of
// silently rendering the frozen t=0 DOM.
it("binds the sole registered timeline when its key does not match the root id", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
// Registered under "wrong-key", not "main".
window.__timelines = {
"wrong-key": createMockTimeline(7),
};
initSandboxRuntimeModular();
expect(window.__player?.getDuration()).toBe(7);
});
// #6: when the root id is unmatched AND two timelines are registered, the
// fallback is ambiguous, so nothing is bound and the loud warning fires.
it("does not bind any timeline when the root id is unmatched and multiple are registered", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = {
"wrong-key-a": createMockTimeline(7),
"wrong-key-b": createMockTimeline(9),
};
initSandboxRuntimeModular();
const warned = warnSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(window.__player?.getDuration()).toBe(0);
expect(warned).toContain("[hyperframes]");
expect(warned).toContain("Root timeline not bound");
expect(warned).toContain("wrong-key-a");
expect(warned).toContain("wrong-key-b");
});
it("pauses nested media that is outside the timed-media cache after a seek", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-translation");
child.setAttribute("data-start", "20");
child.setAttribute("data-duration", "16");
root.appendChild(child);
const video = document.createElement("video");
child.appendChild(video);
Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true });
Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true });
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
const pause = () => {
Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true });
};
video.load = () => {};
video.pause = pause;
window.__timelines = {
main: createMockTimeline(40),
"slide-translation": createMockTimeline(16),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(29);
expect(video.paused).toBe(true);
expect(video.currentTime).toBe(9);
});
// Regression (#1838): a video authoring its OWN data-start (the normal case
// for a timed clip the studio positions on a track) took a fast literal-
// value path that skipped adding the host composition's start offset —
// unlike the no-own-data-start case above, which already went through
// resolveStartForElement and got the offset for free. The video played
// from the ROOT timeline's time instead of holding until its parent scene
// began, desyncing from the correctly-offset GSAP overlay in the same scene.
it("offsets a nested video's own data-start by its host composition's start", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "scene-2");
child.setAttribute("data-start", "20");
child.setAttribute("data-duration", "16");
root.appendChild(child);
const video = document.createElement("video");
// Authored relative to scene-2's own local timeline, not the root's.
video.setAttribute("data-start", "0");
video.setAttribute("data-duration", "16");
child.appendChild(video);
Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true });
Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true });
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
video.load = () => {};
video.play = () => Promise.resolve();
window.__timelines = {
main: createMockTimeline(40),
"scene-2": createMockTimeline(16),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
// Root t=25 is 5s into scene-2 (which starts at root t=20) — the video
// must be 5s into its own local playback, not 25s (root time).
player?.seek(25);
expect(video.currentTime).toBe(5);
});
it("keeps a scene-local video visible inside a later template-mounted host", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "6");
root.setAttribute("data-width", "360");
root.setAttribute("data-height", "640");
document.body.appendChild(root);
const firstHost = document.createElement("div");
firstHost.setAttribute("data-composition-id", "first");
firstHost.setAttribute("data-composition-file", "compositions/first.html");
firstHost.setAttribute("data-start", "0");
firstHost.setAttribute("data-duration", "3");
root.appendChild(firstHost);
const firstVideo = document.createElement("video");
firstVideo.setAttribute("data-start", "0");
firstVideo.setAttribute("data-duration", "3");
firstHost.appendChild(firstVideo);
const secondHost = document.createElement("div");
secondHost.setAttribute("data-composition-id", "second");
secondHost.setAttribute("data-composition-file", "compositions/second.html");
secondHost.setAttribute("data-start", "3");
secondHost.setAttribute("data-duration", "3");
root.appendChild(secondHost);
const secondVideo = document.createElement("video");
secondVideo.setAttribute("data-start", "0");
secondVideo.setAttribute("data-duration", "3");
secondHost.appendChild(secondVideo);
window.__timelines = {
main: createMockTimeline(6),
first: createMockTimeline(3),
second: createMockTimeline(3),
};
initSandboxRuntimeModular();
window.__player?.renderSeek(4);
expect(firstHost.style.visibility).toBe("hidden");
expect(firstVideo.style.visibility).toBe("hidden");
expect(secondHost.style.visibility).toBe("visible");
expect(secondVideo.style.visibility).toBe("visible");
});
it("resolves media starts through arbitrarily nested composition hosts", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const outerHost = document.createElement("div");
outerHost.setAttribute("data-composition-id", "outer");
outerHost.setAttribute("data-composition-file", "outer.html");
outerHost.setAttribute("data-start", "2");
outerHost.setAttribute("data-duration", "6");
root.appendChild(outerHost);
const innerHost = document.createElement("div");
innerHost.setAttribute("data-composition-id", "inner");
innerHost.setAttribute("data-composition-file", "inner.html");
innerHost.setAttribute("data-start", "3");
innerHost.setAttribute("data-duration", "3");
outerHost.appendChild(innerHost);
const video = document.createElement("video");
video.setAttribute("data-start", "1");
video.setAttribute("data-duration", "1");
innerHost.appendChild(video);
window.__timelines = {
main: createMockTimeline(10),
outer: createMockTimeline(6),
inner: createMockTimeline(3),
};
initSandboxRuntimeModular();
expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(6);
window.__player?.renderSeek(5.5);
expect(video.style.visibility).toBe("hidden");
window.__player?.renderSeek(6.5);
expect(video.style.visibility).toBe("visible");
});
it("uses the canonical resolver for reference starts, auto-start media, and inline hosts", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const intro = document.createElement("section");
intro.id = "intro";
intro.setAttribute("data-start", "0");
intro.setAttribute("data-duration", "2");
root.appendChild(intro);
const inlineHost = document.createElement("div");
inlineHost.setAttribute("data-composition-id", "inline");
inlineHost.setAttribute("data-start", "intro + 1");
inlineHost.setAttribute("data-duration", "2");
root.appendChild(inlineHost);
const video = document.createElement("video");
video.setAttribute("data-hf-auto-start", "true");
video.setAttribute("data-duration", "2");
inlineHost.appendChild(video);
window.__timelines = {
main: createMockTimeline(10),
inline: createMockTimeline(2),
};
initSandboxRuntimeModular();
expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(3);
});
it("updates visibility for timed elements inside nested compositions", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "nested");
child.setAttribute("data-start", "10");
child.setAttribute("data-duration", "10");
root.appendChild(child);
const sceneA = document.createElement("section");
sceneA.id = "scene-a";
sceneA.setAttribute("data-start", "0");
sceneA.setAttribute("data-duration", "4");
child.appendChild(sceneA);
const sceneB = document.createElement("section");
sceneB.id = "scene-b";
sceneB.setAttribute("data-start", "4");
sceneB.setAttribute("data-duration", "4");
child.appendChild(sceneB);
window.__timelines = {
main: createMockTimeline(20),
nested: createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(11);
expect(sceneA.style.visibility).toBe("visible");
expect(sceneB.style.visibility).toBe("hidden");
player?.seek(15);
expect(sceneA.style.visibility).toBe("hidden");
expect(sceneB.style.visibility).toBe("visible");
});
it("hides GSAP tween targets inside a hidden timed clip (issue #1387)", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const captionOne = document.createElement("div");
captionOne.id = "t01";
captionOne.setAttribute("data-start", "0");
captionOne.setAttribute("data-duration", "4");
root.appendChild(captionOne);
const lineOne = document.createElement("div");
lineOne.className = "line";
// Studio stamps full-duration pseudo-clips on GSAP tween targets.
lineOne.setAttribute("data-start", "0");
lineOne.setAttribute("data-duration", "8");
captionOne.appendChild(lineOne);
const captionTwo = document.createElement("div");
captionTwo.id = "t02";
captionTwo.setAttribute("data-start", "4");
captionTwo.setAttribute("data-duration", "4");
root.appendChild(captionTwo);
const lineTwo = document.createElement("div");
lineTwo.className = "line";
lineTwo.setAttribute("data-start", "0");
lineTwo.setAttribute("data-duration", "8");
captionTwo.appendChild(lineTwo);
window.__timelines = {
main: createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(1);
expect(captionOne.style.visibility).toBe("visible");
expect(lineOne.style.visibility).toBe("visible");
expect(captionTwo.style.visibility).toBe("hidden");
expect(lineTwo.style.visibility).toBe("hidden");
player?.seek(5);
expect(captionOne.style.visibility).toBe("hidden");
expect(lineOne.style.visibility).toBe("hidden");
expect(captionTwo.style.visibility).toBe("visible");
expect(lineTwo.style.visibility).toBe("visible");
});
});
it("hides timed descendants inside a hidden timed clip in render mode", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const panel = document.createElement("div");
panel.id = "panel";
panel.setAttribute("data-start", "0");
panel.setAttribute("data-duration", "2");
root.appendChild(panel);
const bottomBand = document.createElement("div");
bottomBand.className = "bottom-band";
// Regression shape: a child strip outlives its parent scene. Without
// ancestor suppression it can paint through after the parent has ended.
bottomBand.setAttribute("data-start", "0");
bottomBand.setAttribute("data-duration", "8");
panel.appendChild(bottomBand);
window.__timelines = {
main: createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(3);
expect(panel.style.visibility).toBe("hidden");
expect(bottomBand.style.visibility).toBe("hidden");
});
it("forces data-hidden timed elements out of layout until the attribute is removed", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hiddenClip = document.createElement("div");
hiddenClip.style.position = "absolute";
hiddenClip.setAttribute("data-start", "2");
hiddenClip.setAttribute("data-duration", "4");
hiddenClip.setAttribute("data-hidden", "");
root.appendChild(hiddenClip);
window.__timelines = {
main: createMockTimeline(10),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(0);
expect(hiddenClip.style.display).toBe("none");
player?.seek(3);
expect(hiddenClip.style.display).toBe("none");
player?.seek(7);
expect(hiddenClip.style.display).toBe("none");
hiddenClip.removeAttribute("data-hidden");
player?.seek(3);
expect(hiddenClip.style.visibility).toBe("visible");
expect(hiddenClip.style.display).toBe("");
player?.seek(7);
expect(hiddenClip.style.visibility).toBe("hidden");
expect(hiddenClip.style.display).toBe("");
});
it("excludes a data-hidden audio clip from Web Audio scheduling", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hiddenAudio = document.createElement("audio");
hiddenAudio.setAttribute("data-start", "0");
hiddenAudio.setAttribute("data-duration", "10");
hiddenAudio.setAttribute("data-hidden", "");
hiddenAudio.load = () => {};
hiddenAudio.play = vi.fn(() => Promise.resolve());
root.appendChild(hiddenAudio);
const audibleAudio = document.createElement("audio");
audibleAudio.setAttribute("data-start", "0");
audibleAudio.setAttribute("data-duration", "10");
audibleAudio.load = () => {};
audibleAudio.play = vi.fn(() => Promise.resolve());
root.appendChild(audibleAudio);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
// `scheduleMediaElementPlayback`, not `decodeAudioElement`: the media-element
// transport is the path the runtime tries FIRST for audio, and the decoded
// buffer is only its fallback. What is under test either way is which
// ELEMENTS get scheduled at all.
const scheduleSpy = vi
.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback")
.mockResolvedValue(null);
const player = window.__player;
player?.play();
player?.seek(0);
expect(scheduleSpy).toHaveBeenCalledTimes(1);
expect(scheduleSpy.mock.calls[0]?.[0]).toBe(audibleAudio);
});
it("reschedules only when a data-hidden mutation actually moved something", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hiddenAudio = document.createElement("audio");
hiddenAudio.setAttribute("data-start", "0");
hiddenAudio.setAttribute("data-duration", "10");
hiddenAudio.setAttribute("data-hidden", "");
hiddenAudio.load = () => {};
hiddenAudio.play = vi.fn(() => Promise.resolve());
root.appendChild(hiddenAudio);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const stopSpy = vi.spyOn(WebAudioTransport.prototype, "stopAll");
const player = window.__player;
player?.play();
// A seek stops the transport itself, so the COUNT is the measure. Without
// the dirty gate the reschedule fired on every visibility pass, adding a
// second stop — an audible stop-and-restart across the whole mix — to
// rebuild an identical active set.
stopSpy.mockClear();
player?.seek(1, { keepPlaying: true });
const seekOnly = stopSpy.mock.calls.length;
// The dirty flag is set by a data-hidden MUTATION, so the toggle is the
// gesture; a plain seek never reaches the reschedule at all.
stopSpy.mockClear();
hiddenAudio.removeAttribute("data-hidden");
player?.seek(2, { keepPlaying: true });
const afterToggle = stopSpy.mock.calls.length;
expect(seekOnly).toBe(1);
expect(afterToggle).toBe(seekOnly + 1);
});
it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
// Two separately-toggled audio clips (not a wrapper div — the visibility
// sweep only walks [data-start] nodes, so the attribute must sit on each
// timed element itself, matching how the eye button hides per-element).
const audioA = document.createElement("audio");
audioA.setAttribute("data-start", "0");
audioA.setAttribute("data-duration", "10");
audioA.setAttribute("data-hidden", "");
audioA.load = () => {};
audioA.play = vi.fn(() => Promise.resolve());
root.appendChild(audioA);
const audioB = document.createElement("audio");
audioB.setAttribute("data-start", "0");
audioB.setAttribute("data-duration", "10");
audioB.setAttribute("data-hidden", "");
audioB.load = () => {};
audioB.play = vi.fn(() => Promise.resolve());
root.appendChild(audioB);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const player = window.__player;
// play() alone (no seek) already runs one visibility pass while the clock
// is playing, registering both clips as hidden — the baseline this test
// toggles away from.
player?.play();
const scheduleSpy = vi
.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback")
.mockResolvedValue(null);
const generationSpy = vi.spyOn(WebAudioTransport.prototype, "startGeneration");
// Both become visible in the SAME sync pass — must still be one reschedule.
// keepPlaying: a plain seek() pauses the clock before re-syncing visibility,
// which would make the hiddenAudioDirty branch's isPlaying() gate a no-op.
audioA.removeAttribute("data-hidden");
audioB.removeAttribute("data-hidden");
player?.seek(1, { keepPlaying: true });
expect(generationSpy).toHaveBeenCalledTimes(1);
expect(scheduleSpy).toHaveBeenCalledTimes(2);
});
// Scheduling does NOT replace the active set: it bumps a generation, which
// only rejects schedules still in flight. Every source already started keeps
// playing and there is no per-element dedup, so rescheduling on its own laid
// a second buffer source over every sounding clip — the whole mix doubled,
// slightly out of phase, from one mute click until the next pause.
it("stops the running sources before rescheduling on a data-hidden toggle", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("data-hidden", "");
audio.load = () => {};
audio.play = vi.fn(() => Promise.resolve());
root.appendChild(audio);
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
const player = window.__player;
player?.play();
vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null);
const stopSpy = vi.spyOn(WebAudioTransport.prototype, "stopAll");
const generationSpy = vi.spyOn(WebAudioTransport.prototype, "startGeneration");
audio.removeAttribute("data-hidden");
player?.seek(1, { keepPlaying: true });
expect(generationSpy).toHaveBeenCalledTimes(1);
// Two: `seek` clears the graph on its way in, and the toggle's own
// reschedule clears it again. Only the second one is what this covers —
// without it the count is 1 and the reschedule stacks on live sources.
expect(stopSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
// Order matters, not just presence: stopping AFTER the reschedule would
// silence the clips it had just started.
const lastStop = Math.max(...stopSpy.mock.invocationCallOrder);
expect(lastStop).toBeLessThan(generationSpy.mock.invocationCallOrder[0] ?? 0);
});
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const caption = document.createElement("div");
caption.id = "t01";
caption.setAttribute("data-start", "0");
caption.setAttribute("data-duration", "4");
root.appendChild(caption);
const line = document.createElement("div");
line.className = "line";
caption.appendChild(line);
const tweenTarget = {
targets: () => [line],
};
const timeline = createMockTimeline(8) as RuntimeTimelineLike & {
getChildren: (nested?: boolean) => Array<{ targets: () => Element[] }>;
};
timeline.getChildren = () => [tweenTarget];
window.__timelines = {
main: timeline,
};
initSandboxRuntimeModular();
expect(line.hasAttribute("data-start")).toBe(false);
expect(line.hasAttribute("data-duration")).toBe(false);
});
});
it("hides tween targets inside inactive multi-panel beats (niemmo panel stack)", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "niemmo-launch-50");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "50");
root.setAttribute("data-width", "1280");
root.setAttribute("data-height", "720");
document.body.appendChild(root);
const panelA = document.createElement("div");
panelA.className = "panel clip";
panelA.setAttribute("data-composition-id", "cold-open");
panelA.setAttribute("data-start", "0");
panelA.setAttribute("data-duration", "2");
root.appendChild(panelA);
const headlineA = document.createElement("h1");
headlineA.className = "co-headline";
headlineA.setAttribute("data-start", "0");
headlineA.setAttribute("data-duration", "50");
panelA.appendChild(headlineA);
const panelB = document.createElement("div");
panelB.className = "panel clip";
panelB.setAttribute("data-composition-id", "problem-dev-beat");
panelB.setAttribute("data-start", "2");
panelB.setAttribute("data-duration", "2.5");
root.appendChild(panelB);
const headlineB = document.createElement("h1");
headlineB.className = "pb-headline";
headlineB.setAttribute("data-start", "0");
headlineB.setAttribute("data-duration", "50");
panelB.appendChild(headlineB);
window.__timelines = {
"niemmo-launch-50": createMockTimeline(50),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(1);
expect(panelA.style.visibility).toBe("visible");
expect(headlineA.style.visibility).toBe("visible");
expect(panelB.style.visibility).toBe("hidden");
expect(headlineB.style.visibility).toBe("hidden");
player?.seek(3);
expect(panelA.style.visibility).toBe("hidden");
expect(headlineA.style.visibility).toBe("hidden");
expect(panelB.style.visibility).toBe("visible");
expect(headlineB.style.visibility).toBe("visible");
});
});
it("clamps nested media to the authored host window on seek", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-translation");
child.setAttribute("data-start", "20");
child.setAttribute("data-duration", "16");
root.appendChild(child);
const video = document.createElement("video");
child.appendChild(video);
Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true });
Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true });
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
const pause = () => {
Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true });
};
video.load = () => {};
video.pause = pause;
window.__timelines = {
main: createMockTimeline(40),
"slide-translation": createMockTimeline(16),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.seek(37);
expect(video.paused).toBe(true);
expect(video.currentTime).toBe(0);
});
it("activates sub-composition timelines at data-start near 0 during renderSeek", () => {
// Regression: sub-compositions starting at or near t=0 had their GSAP
// sub-timelines ignored during render because renderSeek did not
// activate (unpause) nested child timelines before seeking the root.
// The children were added to the root while paused, and GSAP's
// totalTime() does not propagate to paused children.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "24");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hookHost = document.createElement("div");
hookHost.setAttribute("data-composition-id", "hook");
hookHost.setAttribute("data-start", "0.001");
hookHost.setAttribute("data-duration", "2");
hookHost.setAttribute("data-track-index", "0");
hookHost.classList.add("clip");
root.appendChild(hookHost);
const laterHost = document.createElement("div");
laterHost.setAttribute("data-composition-id", "tweet");
laterHost.setAttribute("data-start", "1.5");
laterHost.setAttribute("data-duration", "4.5");
laterHost.setAttribute("data-track-index", "1");
laterHost.classList.add("clip");
root.appendChild(laterHost);
const hookTimeline = createMockTimeline(2);
const tweetTimeline = createMockTimeline(4.5);
const rootTimeline = createMockTimeline(24);
window.__timelines = {
main: rootTimeline,
hook: hookTimeline,
tweet: tweetTimeline,
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
// Simulate that the hook timeline was paused (as happens when
// children are added to a paused root timeline in GSAP)
hookTimeline.paused!(true);
tweetTimeline.paused!(true);
// Seek to 0.5s — well within the hook's window [0.001, 2.001]
player?.renderSeek(0.5);
// renderSeek should activate (unpause) all child timelines before
// seeking the root. Without the fix, children stay paused and GSAP's
// totalTime() propagation skips them, leaving elements at initial CSS
// state (opacity: 0).
expect(hookTimeline.paused!()).toBe(false);
expect(tweetTimeline.paused!()).toBe(false);
// The hook host should be visible at t=0.5
expect(hookHost.style.visibility).toBe("visible");
});
it("seeks child compositions in source time using host offset and playback rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "child");
child.setAttribute("data-start", "3");
child.setAttribute("data-duration", "8");
child.setAttribute("data-playback-start", "1.5");
child.setAttribute("data-playback-rate", "2");
root.appendChild(child);
const childTimeline = createMockTimeline(6);
window.__timelines = { main: createMockTimeline(20), child: childTimeline };
initSandboxRuntimeModular();
window.__player?.renderSeek(5);
expect(childTimeline.time()).toBeCloseTo(5.5);
window.__player?.renderSeek(10);
expect(childTimeline.time()).toBe(6);
});
it("keeps the root GSAP render nudge for normal frames but not silent probes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;
window.__player?.renderSeek(2);
expect(seekCalls).toEqual([
{ time: 2, suppressEvents: false },
{ time: 2.001, suppressEvents: true },
{ time: 2, suppressEvents: true },
]);
seekCalls.length = 0;
window.__player?.renderSeek(3, { suppressEvents: true });
expect(seekCalls).toEqual([{ time: 3, suppressEvents: true }]);
});
it("does not nudge root GSAP timelines that contain zero-duration callbacks", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
Object.assign(rootTimeline, {
getChildren: () => [
{
vars: { onComplete: () => {} },
duration: () => 0,
totalDuration: () => 0,
},
],
});
window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;
window.__player?.renderSeek(2);
expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]);
});
it("shows pip video at global start time even when host composition starts late", () => {
// Regression: resolveStartForElement used to add the host composition's start on top of
// the video's own data-start, causing double-offset. A pip video with data-start="45.40"
// inside a host at data-start="45.40" would resolve to 90.80 and stay permanently hidden.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "scene-pip");
host.setAttribute("data-composition-file", "compositions/pip.html");
host.setAttribute("data-start", "45.40");
host.setAttribute("data-duration", "7.06");
root.appendChild(host);
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "scene-pip");
host.appendChild(innerRoot);
// pip-wired video: data-start is authored in global time (same value as host)
const pipVideo = document.createElement("video");
pipVideo.setAttribute("data-start", "45.40");
pipVideo.setAttribute("data-duration", "7.06");
Object.defineProperty(pipVideo, "paused", { value: true, configurable: true });
Object.defineProperty(pipVideo, "readyState", { value: 0, configurable: true });
Object.defineProperty(pipVideo, "currentTime", {
value: 0,
writable: true,
configurable: true,
});
pipVideo.load = () => {};
innerRoot.appendChild(pipVideo);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(60),
"scene-pip": createMockTimeline(7.06),
};
initSandboxRuntimeModular();
expect(window.__hfResolveMediaStartSeconds?.(pipVideo)).toBeCloseTo(45.4);
const player = (
window as Window & {
__player?: { seek: (timeSeconds: number) => void };
}
).__player;
expect(player).toBeDefined();
// Before the fix: resolveStartForElement(pipVideo) = 45.40 + 45.40 = 90.80, so the
// video would be hidden at t=46 (90.80 > 46). After the fix: start = 45.40, visible.
player?.seek(46);
expect(pipVideo.style.visibility).toBe("visible");
player?.seek(53);
expect(pipVideo.style.visibility).toBe("hidden");
player?.seek(44);
expect(pipVideo.style.visibility).toBe("hidden");
});
it("shows auto-injected video at host time, not at t=0", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "intro");
host.setAttribute("data-start", "10");
host.setAttribute("data-duration", "5");
root.appendChild(host);
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "intro");
host.appendChild(innerRoot);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
video.setAttribute("data-hf-auto-start", "");
video.setAttribute("data-duration", "5");
Object.defineProperty(video, "paused", { value: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
Object.defineProperty(video, "currentTime", {
value: 0,
writable: true,
configurable: true,
});
video.load = () => {};
innerRoot.appendChild(video);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(30),
intro: createMockTimeline(5),
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { seek: (timeSeconds: number) => void };
}
).__player;
expect(player).toBeDefined();
player?.seek(12);
expect(video.style.visibility).toBe("visible");
player?.seek(5);
expect(video.style.visibility).toBe("hidden");
player?.seek(16);
expect(video.style.visibility).toBe("hidden");
});
it("allocates color grading only for the active timed media", () => {
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "4");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const futureComposition = document.createElement("div");
futureComposition.id = "future-composition";
futureComposition.setAttribute("data-start", "2");
root.appendChild(futureComposition);
for (const [id, start] of [
["first", "0"],
["second", "2"],
]) {
const video = document.createElement("video");
video.id = id;
video.setAttribute("data-start", start);
video.setAttribute("data-duration", "2");
video.setAttribute("data-color-grading", '{"adjust":{"exposure":0.1}}');
Object.defineProperty(video, "paused", { value: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
video.load = () => {};
root.appendChild(video);
}
window.__timelines = { main: createMockTimeline(4) };
initSandboxRuntimeModular();
expect(getContextSpy).toHaveBeenCalledTimes(1);
expect(document.getElementById("first")?.style.visibility).toBe("visible");
expect(document.getElementById("second")?.style.visibility).toBe("hidden");
expect(futureComposition.style.visibility).toBe("");
expect(futureComposition.style.display).toBe("");
window.__player?.seek(3);
expect(getContextSpy).toHaveBeenCalledTimes(2);
expect(document.getElementById("first")?.style.visibility).toBe("hidden");
expect(document.getElementById("second")?.style.visibility).toBe("visible");
});
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "4");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "scene");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "4");
root.appendChild(child);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "4");
Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" },
configurable: true,
});
Object.defineProperty(audio, "networkState", {
value: HTMLMediaElement.NETWORK_NO_SOURCE,
configurable: true,
});
Object.defineProperty(audio, "readyState", {
value: HTMLMediaElement.HAVE_NOTHING,
configurable: true,
});
Object.defineProperty(audio, "paused", { value: true, configurable: true });
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
audio.load = () => {};
audio.play = vi.fn(() => Promise.reject(new Error("format error")));
root.appendChild(audio);
const childTimeline = createMockTimeline(4);
window.__timelines = {
scene: childTimeline,
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.play();
raf.step(1_000);
expect(player?.isPlaying()).toBe(true);
expect(player?.getTime()).toBeCloseTo(1, 1);
expect(childTimeline.time()).toBeCloseTo(1, 1);
});
it.each([24, 30, 60, 30_000 / 1_001])(
"preserves public playback state across keepPlaying seeks at %s fps",
(fps) => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
vi.spyOn(console, "info").mockImplementation(() => {});
window.requestAnimationFrame =
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
document.body.innerHTML = `
<div
data-composition-id="main"
data-root="true"
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
></div>
`;
window.__timelines = { main: createMockTimeline(10) };
window.__HF_EXPORT_RENDER_SEEK_CONFIG = {
fps,
fpsSource: "render-options",
};
initSandboxRuntimeModular();
const player = window.__player;
player?.play();
raf.step(500);
player?.seek(2.07, { keepPlaying: true });
const quantized = Math.floor(2.07 * fps + 1e-9) / fps;
expect(player?.isPlaying()).toBe(true);
expect(player?.getTime()).toBeCloseTo(quantized, 6);
raf.step(500);
expect(player?.isPlaying()).toBe(true);
expect(player?.getTime()).toBeCloseTo(quantized + 0.5, 5);
},
);
it("ignores the async media-metadata duration rebind once render capture has started seeking frames (regression HF#2550)", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
document.body.appendChild(video);
// A root timeline with no usable duration yet — mirrors a composition
// whose length is derived from a full-length <video> that hasn't reported
// its metadata. window.gsap is needed because resolveRootTimelineFromDocument
// builds a fresh duration-floor wrapper timeline via gsap.timeline().
(
window as unknown as {
gsap?: { timeline: () => ReturnType<typeof createPaddableMockTimeline> };
}
).gsap = {
timeline: () => createPaddableMockTimeline(0),
};
window.__timelines = { main: createMockTimeline(0) };
// Only a real producer render/export page sets this (fileServer.ts's
// pre-head script) — required alongside renderCaptureSeekStarted so the
// gate doesn't also disable Studio's own preview-iframe rebind.
window.__HF_EXPORT_RENDER_SEEK_CONFIG = { fps: 30, fpsSource: "default" };
const postMessageSpy = vi.spyOn(window, "postMessage");
try {
initSandboxRuntimeModular();
// The render/producer capture protocol has claimed the timeline and is
// now driving frames deterministically (mirrors the engine's
// window.__hf.seek(t) -> player.renderSeek(t) bridge).
window.__player?.renderSeek(0);
// Let the runtime's own deferred re-bind attempt (init.ts's
// `setTimeout(() => maybePublishRenderReady(), 0)`, unrelated to media
// metadata) settle first, so only the metadata path below is under test.
await new Promise((resolve) => setTimeout(resolve, 10));
// Video metadata resolves late — after capture has started, the exact
// HF#2550 race (Docker/slow-I/O environments hit this; fast native
// environments resolve metadata before capture begins and never do).
Object.defineProperty(video, "duration", { value: 12, configurable: true });
video.dispatchEvent(new Event("loadedmetadata"));
// Clears init.ts's internal METADATA_REBIND_DEBOUNCE_MS (100ms, not exported).
await new Promise((resolve) => setTimeout(resolve, 150));
const rebindMessages = postMessageSpy.mock.calls
.map(([message]) => message as { code?: string } | undefined)
.filter((message) => message?.code === "timeline_rebind_after_media_metadata");
expect(rebindMessages).toHaveLength(0);
} finally {
delete (window as { gsap?: unknown }).gsap;
}
});
it("still applies the media-metadata duration rebind after renderSeek in Studio preview (no export render-seek config)", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
document.body.appendChild(video);
(
window as unknown as {
gsap?: { timeline: () => ReturnType<typeof createPaddableMockTimeline> };
}
).gsap = {
timeline: () => createPaddableMockTimeline(0),
};
window.__timelines = { main: createMockTimeline(0) };
// No window.__HF_EXPORT_RENDER_SEEK_CONFIG here — Studio's preview iframe
// never sets it, and useTimelinePlayer's overhang fallback drives
// renderSeek there too. The rebind must still fire for this case.
const postMessageSpy = vi.spyOn(window, "postMessage");
try {
initSandboxRuntimeModular();
window.__player?.renderSeek(0);
await new Promise((resolve) => setTimeout(resolve, 10));
Object.defineProperty(video, "duration", { value: 12, configurable: true });
video.dispatchEvent(new Event("loadedmetadata"));
// Clears init.ts's internal METADATA_REBIND_DEBOUNCE_MS (100ms, not exported).
await new Promise((resolve) => setTimeout(resolve, 150));
const rebindMessages = postMessageSpy.mock.calls
.map(([message]) => message as { code?: string } | undefined)
.filter((message) => message?.code === "timeline_rebind_after_media_metadata");
expect(rebindMessages).toHaveLength(1);
} finally {
delete (window as { gsap?: unknown }).gsap;
}
});
it("sets __renderReady only after timeline is bound, not at __playerReady time", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = {
main: createMockTimeline(10),
};
initSandboxRuntimeModular();
expect(window.__playerReady).toBe(true);
expect(window.__renderReady).toBe(true);
expect(window.__player).toBeDefined();
});
it("waits for GSAP batching to finish before publishing render readiness", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
let timelineDuration = 0;
const timeline = createMockTimeline(0);
timeline.duration = () => timelineDuration;
window.__timelines = {
main: timeline,
};
window.__hfTimelinesBuilding = true;
initSandboxRuntimeModular();
expect(window.__playerReady).toBe(true);
expect(window.__renderReady).toBe(false);
expect(window.__player?.getDuration()).toBe(0);
timelineDuration = 10;
window.__hfTimelinesBuilding = false;
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(10);
});
it("resumes readiness when GSAP batching starts after runtime initialization", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
let timelineDuration = 0;
const timeline = createMockTimeline(0);
timeline.duration = () => timelineDuration;
window.__timelines = { main: timeline };
window.__hfTimelinesBuilding = false;
initSandboxRuntimeModular();
expect(window.__renderReady).toBe(true);
window.__hfTimelinesBuilding = true;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(window.__renderReady).toBe(false);
timelineDuration = 10;
window.__hfTimelinesBuilding = false;
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(10);
});
it("waits for THREE.DefaultLoadingManager to drain before publishing render readiness", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = {
main: createMockTimeline(10),
};
// Simulate THREE with an in-flight asset load — same shape the three adapter
// reads, no actual three.js dependency in tests. `itemsTotal > itemsLoaded`
// means "loads pending"; resolving the wait fires `onLoad` after wrapping.
const mgr: {
itemsLoaded: number;
itemsTotal: number;
onStart?: ((url: string, loaded: number, total: number) => void) | null;
onLoad?: (() => void) | null;
} = {
itemsLoaded: 0,
itemsTotal: 1,
onStart: null,
onLoad: null,
};
(window as unknown as { THREE: { DefaultLoadingManager: typeof mgr } }).THREE = {
DefaultLoadingManager: mgr,
};
initSandboxRuntimeModular();
// Player ready, render NOT ready because an asset is pending.
expect(window.__playerReady).toBe(true);
expect(window.__renderReady).toBe(false);
expect(window.__player?.getDuration()).toBe(10);
// Simulate the asset finishing: drain the queue and fire the (now-wrapped)
// onLoad. The adapter's wrapper resolves the readiness promise, which
// triggers a re-publish.
mgr.itemsLoaded = 1;
mgr.onLoad?.();
await Promise.resolve();
await Promise.resolve();
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(10);
});
it("sets __renderReady even without a GSAP timeline (CSS/WAAPI compositions)", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = {};
initSandboxRuntimeModular();
expect(window.__playerReady).toBe(true);
expect(window.__renderReady).toBe(true);
});
it("infers hf.duration from a CSS animation's computed timing without data-duration or a GSAP timeline", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const animated = document.createElement("div");
animated.style.animationName = "fadeIn";
root.appendChild(animated);
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
const real =
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
return {
...real,
animationName: target === animated ? "fadeIn" : "none",
} as CSSStyleDeclaration;
});
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
{
currentTime: 0,
pause: () => {},
play: () => {},
effect: { getComputedTiming: () => ({ endTime: 6000 }) },
} as unknown as Animation,
];
window.__timelines = {};
initSandboxRuntimeModular();
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(6);
});
it("still requires data-duration when a CSS animation is infinite (unbounded end time)", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const animated = document.createElement("div");
animated.style.animationName = "spin";
root.appendChild(animated);
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
const real =
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
return {
...real,
animationName: target === animated ? "spin" : "none",
} as CSSStyleDeclaration;
});
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
{
currentTime: 0,
pause: () => {},
play: () => {},
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
} as unknown as Animation,
];
window.__timelines = {};
initSandboxRuntimeModular();
// No data-duration, no GSAP timeline, and the only animation is
// unbounded — duration cannot be inferred, so it stays at 0. This is the
// case that must still surface the "add data-duration" lint/runtime error.
expect(window.__player?.getDuration()).toBe(0);
});
it("infers hf.duration from a registered Lottie animation without data-duration or a GSAP timeline", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
(window as Window & { __hfLottie?: unknown[] }).__hfLottie = [
{ play: () => {}, pause: () => {}, totalFrames: 150, frameRate: 30 },
];
window.__timelines = {};
initSandboxRuntimeModular();
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(5);
delete (window as Window & { __hfLottie?: unknown[] }).__hfLottie;
});
it("regression: a GSAP timeline's duration is unaffected by adapter duration inference", () => {
// A GSAP composition can legitimately have an incidental, short CSS
// animation running alongside the timeline (e.g. a decorative shimmer).
// The GSAP timeline must remain the source of truth for total duration —
// the new adapter-inference floor (resolveAdapterDurationFloorSeconds)
// must not shrink or otherwise override it.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const shimmer = document.createElement("div");
shimmer.style.animationName = "shimmer";
root.appendChild(shimmer);
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
return {
animationName: target === shimmer ? "shimmer" : "none",
} as CSSStyleDeclaration;
});
(shimmer as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
{
currentTime: 0,
pause: () => {},
play: () => {},
// Much shorter than the GSAP timeline below (2s vs 12s) — must not
// become the reported duration.
effect: { getComputedTiming: () => ({ endTime: 2000 }) },
} as unknown as Animation,
];
window.__timelines = { root: createMockTimeline(12) };
initSandboxRuntimeModular();
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(12);
});
it("seeks captured timeline to currentTime on initial bind", () => {
const seekTimes: number[] = [];
const tl = createMockTimeline(5);
const origTotalTime = tl.totalTime;
tl.totalTime = ((time: number, ...rest: unknown[]) => {
seekTimes.push(time);
(origTotalTime as Function).call(tl, time, ...rest);
}) as RuntimeTimelineLike["totalTime"];
document.body.innerHTML = `
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
`;
window.__timelines = { root: tl };
initSandboxRuntimeModular();
expect(seekTimes.length).toBeGreaterThanOrEqual(2);
expect(seekTimes[seekTimes.length - 1]).toBe(0);
});
it("accepts replayed transport controls when the bridge announces ready without duplicate listeners", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "5");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const timeline = createMockTimeline(5);
timeline.timeScale = vi.fn();
window.__timelines = { root: timeline };
const outbound: Array<Record<string, unknown>> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message !== "object" || message === null) return;
const payload = message as Record<string, unknown>;
outbound.push(payload);
if (payload.source !== "hf-preview" || payload.type !== "ready") return;
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "seek",
timeSeconds: 2,
},
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-playback-rate",
playbackRate: 2,
},
}),
);
});
expect(() => initSandboxRuntimeModular()).not.toThrow();
expect(() => initSandboxRuntimeModular()).not.toThrow();
expect(timeline.time()).toBe(2);
expect(timeline.timeScale).toHaveBeenLastCalledWith(2);
expect(outbound.filter((message) => message.type === "ready")).toHaveLength(2);
expect(
outbound.filter(
(message) => message.type === "analytics" && message.event === "composition_seeked",
),
).toHaveLength(2);
});
it("restores timed element visibility after a forced timeline rebind", () => {
document.body.innerHTML = `
<div data-composition-id="root" data-root="true" data-duration="30" data-width="1920" data-height="1080">
<div class="clip" id="clip-expired" data-start="0" data-duration="15.234"></div>
<div class="clip" id="clip-future" data-start="20.83" data-duration="3"></div>
<div class="clip" id="clip-control" data-start="10" data-duration="10"></div>
</div>
`;
const clipExpired = document.querySelector<HTMLElement>("#clip-expired");
const clipFuture = document.querySelector<HTMLElement>("#clip-future");
const clipControl = document.querySelector<HTMLElement>("#clip-control");
window.__timelines = { root: createMockTimeline(30) };
initSandboxRuntimeModular();
window.__player?.seek(16.2);
expect(clipExpired?.style.visibility).toBe("hidden");
expect(clipFuture?.style.visibility).toBe("hidden");
expect(clipControl?.style.visibility).toBe("visible");
if (clipExpired) clipExpired.style.visibility = "visible";
if (clipFuture) clipFuture.style.visibility = "visible";
window.__hfForceTimelineRebind?.();
expect(clipExpired?.style.visibility).toBe("hidden");
expect(clipFuture?.style.visibility).toBe("hidden");
expect(clipControl?.style.visibility).toBe("visible");
});
it("onSetMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("muted", "");
video.muted = true; // browsers auto-sync from attribute; jsdom doesn't
video.setAttribute("src", "avatar.mp4");
root.appendChild(video);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
root.appendChild(audio);
window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(video.defaultMuted).toBe(true);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: false },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: true },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(true);
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "set-muted", muted: false },
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
});
it("onSetMediaOutputMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const video = document.createElement("video");
video.setAttribute("muted", "");
video.muted = true;
video.setAttribute("src", "avatar.mp4");
root.appendChild(video);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
root.appendChild(audio);
window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-media-output-muted",
muted: false,
},
}),
);
expect(video.muted).toBe(true);
expect(audio.muted).toBe(false);
});
it("native media sync opt-out leaves user-started media playing while timeline is paused", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
Object.defineProperty(audio, "duration", { value: 10, configurable: true });
Object.defineProperty(audio, "readyState", {
value: HTMLMediaElement.HAVE_FUTURE_DATA,
configurable: true,
});
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
Object.defineProperty(audio, "paused", { value: true, writable: true, configurable: true });
audio.pause = vi.fn(() => {
Object.defineProperty(audio, "paused", {
value: true,
writable: true,
configurable: true,
});
});
root.appendChild(audio);
window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();
window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-native-media-sync-disabled",
disabled: true,
},
}),
);
Object.defineProperty(audio, "paused", { value: false, writable: true, configurable: true });
vi.mocked(audio.pause).mockClear();
window.__player?.renderSeek(5);
expect(audio.pause).not.toHaveBeenCalled();
});
it("skips the per-frame transport re-seek while a Studio manual-edit gesture is active", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
const seekTimes: number[] = [];
const tl = createMockTimeline(5);
const origTotalTime = tl.totalTime;
tl.totalTime = ((time: number, ...rest: unknown[]) => {
seekTimes.push(time);
(origTotalTime as Function).call(tl, time, ...rest);
}) as RuntimeTimelineLike["totalTime"];
document.body.innerHTML = `
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080">
<div id="dragged" data-hf-studio-manual-edit-gesture="tok-1"></div>
</div>
`;
window.__timelines = { root: tl };
initSandboxRuntimeModular();
// (1) Paused + gesture active → the per-frame transport tick must NOT
// re-seek the timeline, otherwise it re-applies the animated value and
// clobbers the draft writer (gsap.set) that owns the dragged element,
// freezing it mid-drag.
const afterInit = seekTimes.length;
raf.step(16);
raf.step(16);
raf.step(16);
expect(seekTimes.length).toBe(afterInit);
// (2) Playback always wins: with the SAME gesture marker still present, a
// playing clock must keep re-seeking (the gate must never freeze playback).
// Guards the clock.isPlaying() short-circuit — a regression flipping `||`
// to `&&` would skip the seek here and this assertion would catch it.
const player = window.__player;
const beforePlaying = seekTimes.length;
player?.play();
raf.step(16);
expect(seekTimes.length).toBeGreaterThan(beforePlaying);
player?.pause();
// (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes.
document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture");
const beforeResume = seekTimes.length;
raf.step(16);
expect(seekTimes.length).toBeGreaterThan(beforeResume);
});
it("redraws animated grading from the transport clock only during playback", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
document.body.innerHTML = `
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
`;
window.__timelines = { root: createMockTimeline(5) };
initSandboxRuntimeModular();
const runtime = (
window as Window & { __hf?: { colorGrading?: { redrawAnimated: () => number } } }
).__hf?.colorGrading;
if (!runtime) throw new Error("Expected color grading runtime");
const redrawAnimated = vi.spyOn(runtime, "redrawAnimated");
raf.step(16);
expect(redrawAnimated).not.toHaveBeenCalled();
window.__player?.play();
raf.step(16);
expect(redrawAnimated).toHaveBeenCalledTimes(1);
window.__player?.pause();
raf.step(16);
expect(redrawAnimated).toHaveBeenCalledTimes(1);
});
it("keeps a usable bound timeline when the registry entry is replaced", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
document.body.innerHTML = `
<div data-composition-id="root" data-start="0" data-duration="5" data-width="1920" data-height="1080"></div>
`;
const originalTimeline = createMockTimeline(5);
window.__timelines = { root: originalTimeline };
initSandboxRuntimeModular();
const replacementTimeline = createMockTimeline(8);
window.__timelines.root = replacementTimeline;
for (let frame = 0; frame < 60; frame += 1) raf.step(16);
expect(window.__player?.getDuration()).toBe(5);
});
// applyClipLayout force-absolutizes authored root-level timed clips so they
// stack as overlays. But in Studio/preview the runtime also stamps `data-start`
// onto ID'd / GSAP-targeted *flow* children (a <header>/<footer> in a column)
// so the design panel can discover them — those must NOT be force-absolutized,
// or the layout collapses (footer shrink-wraps, `space-between` clusters). The
// marker `data-hf-autostamped` distinguishes them; these tests pin both halves.
describe("applyClipLayout: runtime-stamped clips stay in document flow", () => {
const makeRoot = () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
return root;
};
// jsdom does no layout, so a static clip can report computed top "auto" or
// "" inconsistently. Pin the values the anchor gate keys on so the assertion
// reflects the real-browser path deterministically.
const overrideComputed = (
target: HTMLElement,
overrides: Partial<Record<"position" | "top" | "left" | "bottom" | "right", string>>,
) => {
const real = window.getComputedStyle.bind(window);
vi.spyOn(window, "getComputedStyle").mockImplementation(((
el: Element,
pseudo?: string | null,
) => {
const style = real(el as Element, pseudo ?? undefined);
if (el !== target) return style;
return new Proxy(style, {
get(t, prop) {
if (typeof prop === "string" && prop in overrides) {
return overrides[prop as keyof typeof overrides];
}
const value = Reflect.get(t, prop);
return typeof value === "function" ? value.bind(t) : value;
},
}) as CSSStyleDeclaration;
}) as typeof window.getComputedStyle);
};
it("force-absolutizes an authored data-start clip (baseline behavior preserved)", () => {
const root = makeRoot();
const clip = document.createElement("div");
clip.setAttribute("data-start", "0"); // authored clip, no autostamp marker
root.appendChild(clip);
overrideComputed(clip, {
position: "static",
top: "auto",
left: "auto",
bottom: "auto",
right: "auto",
});
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(clip.style.position).toBe("absolute");
expect(clip.style.top).toBe("0px");
expect(clip.style.left).toBe("0px");
});
it("leaves a runtime-stamped flow child untouched so the layout is preserved", () => {
const root = makeRoot();
const footer = document.createElement("footer");
footer.setAttribute("data-start", "0");
footer.setAttribute("data-hf-autostamped", "1"); // stamped flow child, not an overlay clip
root.appendChild(footer);
overrideComputed(footer, {
position: "static",
top: "auto",
left: "auto",
bottom: "auto",
right: "auto",
});
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
// Skipped entirely: stays in document flow (no forced absolute, no anchor),
// so a flex-column footer keeps full width and `space-between` spreads — the
// preview then matches the rendered video, which never stamps.
expect(footer.style.position).toBe("");
expect(footer.style.top).toBe("");
expect(footer.style.left).toBe("");
});
});
describe("partial registry timelines", () => {
it("survives play/pause/seek when the sole registered timeline lacks pause()", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
// An authored composition can register a PARTIAL timeline — duration/seek
// only. It renders fine (the render path never pauses), so the interactive
// transport must tolerate the missing pause() instead of throwing
// "tl.pause is not a function" (top recurring studio unhandled error).
const partial = createMockTimeline(10) as RuntimeTimelineLike & { pause?: unknown };
delete partial.pause;
window.__timelines = { main: partial as RuntimeTimelineLike };
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
expect(() => {
player?.play();
player?.pause();
player?.seek(1);
player?.renderSeek(2);
}).not.toThrow();
});
});
});