// fallow-ignore-file code-duplication import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; 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; timeline.to = (_target, vars, position) => { paddedDuration = Math.max(paddedDuration, position + Math.max(0, Number(vars.duration) || 0)); }; return timeline; } function createManualRaf() { let now = 0; let nextId = 0; const callbacks = new Map(); 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; }); afterEach(() => { window.__hfRuntimeTeardown?.(); document.body.innerHTML = ""; window.__timelines = {} as Record; 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; vi.restoreAllMocks(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); 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("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 = `
HOLD ME
`; document.body.appendChild(template); window.__timelines = { main: createMockTimeline(3), sub: createMockTimeline(1), }; initSandboxRuntimeModular(); await new Promise((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( ``, { status: 200 }, ), ); window.__timelines = { main: createMockTimeline(3), sub: createMockTimeline(3) }; initSandboxRuntimeModular(); await new Promise((resolve) => window.setTimeout(resolve, 0)); const injectedLink = document.head.querySelector( '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 = '
HOLD ME
'; root.appendChild(child); window.__timelines = { main: createMockTimeline(3), sub: createMockTimeline(1), }; initSandboxRuntimeModular(); await new Promise((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("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("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("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-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 }).__timelines = { main: createMockTimeline(60), "scene-pip": createMockTimeline(7.06), }; initSandboxRuntimeModular(); 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 }).__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("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 = `
`; 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("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 = `
`; window.__timelines = { root: tl }; initSandboxRuntimeModular(); expect(seekTimes.length).toBeGreaterThanOrEqual(2); expect(seekTimes[seekTimes.length - 1]).toBe(0); }); it("restores timed element visibility after a forced timeline rebind", () => { document.body.innerHTML = `
`; const clipExpired = document.querySelector("#clip-expired"); const clipFuture = document.querySelector("#clip-future"); const clipControl = document.querySelector("#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 = `
`; 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("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 = `
`; 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
/