mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes #1838
1561 lines
54 KiB
TypeScript
1561 lines
54 KiB
TypeScript
// 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) => {
|
|
state.time = time;
|
|
},
|
|
totalTime: (time: number) => {
|
|
state.time = 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<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;
|
|
});
|
|
|
|
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;
|
|
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("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("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("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 missing AND two timelines are registered, the
|
|
// fallback is ambiguous, so nothing is bound (the loud warning fires instead).
|
|
it("does not bind any timeline when the root id is unmatched and multiple are registered", () => {
|
|
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();
|
|
|
|
expect(window.__player?.getDuration()).toBe(0);
|
|
});
|
|
|
|
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("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("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<string, RuntimeTimelineLike> }).__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<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("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("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("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("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("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);
|
|
});
|
|
|
|
// 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("");
|
|
});
|
|
});
|
|
});
|