mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional The #2 render failure bucket ("Composition has zero duration") accounts for ~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and Lottie compositions had no source of truth for total duration unless the author remembered to set data-duration on the root element, and the render engine hard-failed capture when neither was present. Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest finite end time it can discover from its own animations (CSS: computed timing offset by data-start; WAAPI: effect.getComputedTiming().endTime; Lottie: totalFrames/frameRate or the player's own duration). Infinite/ unbounded animations correctly return null and still require data-duration. Wires this into the runtime's existing duration-floor resolution (resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the existing media-duration and authored-composition floors, so window.__hf.duration becomes positive without any author action for finite-duration non-GSAP compositions. Three.js is unchanged — no AnimationClip/AnimationMixer inspection exists in that adapter, so data-duration remains required there. Tightens frameCapture.ts's zero-duration fast-fail gate to also check hf.duration directly (not just the two authored signals), so a composition mid-inference isn't fast-failed before its adapter-derived duration lands. Adds a new lint rule (root_composition_missing_duration_source) that errors only on genuinely non-inferable cases: no animation signal at all, Three.js without data-duration, or an infinite/unbounded CSS or WAAPI animation without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie animations, since the runtime now infers those — an autofix that "inserts the inferred value" was considered and rejected: every case the rule flags has no derivable value (an infinite spinner has no finite end time; a duration-less Three.js scene has nothing to measure), so any autofix would have to fabricate a placeholder, trading a loud correct failure for a silent wrong-length render. Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the hyperframes-core determinism-rules/data-attributes references to document the new optionality and the runtime mechanism backing it. Verified end-to-end against the real render pipeline (not just unit tests): a CSS-only composition with a finite 3s animation, no GSAP timeline, and no data-duration now renders a correct 3.000s MP4 via `hyperframes render` (previously: "Composition has zero duration" failure). The infinite-CSS negative control still fails fast with a clear diagnostic, matching the new lint rule. Adds a file-level fallow health exemption for lottie.ts's pre-existing `seek` handler — unrelated to this change, but its line numbers shifted when new functions were added earlier in the file, tripping fallow's inherited-finding fingerprint (documented pattern already used elsewhere in .fallowrc.jsonc for the same reason). Known limitation: the static WAAPI usage detector in the lint rule (/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects whether the "no signal at all" branch fires, and errs toward NOT flagging (reducing false positives) rather than over-flagging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(lint): close 3 correctness gaps in root_composition_missing_duration_source - Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS animation signals, so a commented-out `.animate()` call or a commented `animation: ... infinite` rule can no longer satisfy the "has a duration source" check and mask a real zero-duration render failure. - Broaden the WAAPI detection regex to also match the object-literal (PropertyIndexedKeyframes) form of `.animate()`, e.g. `el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous character class silently missed. Corrected the adjacent comment that incorrectly claimed this shape "can't be a false negative". - Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs that merely contain the substring "infinite" (e.g. `infinite-spin`) by anchoring the `infinite` keyword with hyphen-aware boundaries instead of a bare `\b`. Also makes the longhand `animation-name` + separately declared `animation-iteration-count: infinite` pattern detected consistently. Adds targeted unit tests for each fixed false-positive/false-negative. * fix(runtime): keep finite duration signal when an unbounded animation coexists getInferredDurationSeconds in the CSS and WAAPI adapters returned null outright whenever any animation on the composition was unbounded (infinite iteration count), even when other finite animations on the same composition could still supply a valid duration. This disagreed with the new root_composition_missing_duration_source lint rule, which treats any animation-name as sufficient — so a composition mixing a finite fadeIn with a decorative infinite spin passed lint but still failed at render with "zero duration". Unbounded animations are now skipped when computing the max end time instead of short-circuiting the whole calculation. null is only returned when every animation on the composition is unbounded, i.e. there is no finite signal to fall back on at all. Co-Authored-By: Claude <noreply@anthropic.com> * docs(skills): fix table separator width in data-attributes.md oxfmt flagged the merged Composition Root table from the post-rebase merge of the auto-infer-duration docs onto main's reformatted table — the separator row was one dash short of the header width. * fix(lint): keep infinite-CSS duration rule strict but make its message honest Post-review (Vance): after the finite+infinite adapter fix, the runtime infers a length for a mixed finite+infinite CSS composition, but this lint rule still (intentionally) errors on it — an unbounded animation makes the intended total length ambiguous, so we require explicit data-duration. Keep that strictness (lint is advisory by default; it only blocks under --strict, and data-duration is the one duration signal guaranteed correct across every adapter, known and future). But the message wrongly claimed the render "will fail" — false for the mixed case, where the runtime falls back to the finite animation. Rewrite it to describe the ambiguity honestly, correct the rule's block comment, and add a mixed finite+infinite test asserting it still errors with an honest message. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1654 lines
58 KiB
TypeScript
1654 lines
58 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);
|
|
});
|
|
|
|
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("infers hf.duration from a CSS animation's computed timing without data-duration or a GSAP timeline", () => {
|
|
const root = document.createElement("div");
|
|
root.setAttribute("data-composition-id", "main");
|
|
root.setAttribute("data-root", "true");
|
|
root.setAttribute("data-start", "0");
|
|
root.setAttribute("data-width", "1920");
|
|
root.setAttribute("data-height", "1080");
|
|
document.body.appendChild(root);
|
|
|
|
const animated = document.createElement("div");
|
|
animated.style.animationName = "fadeIn";
|
|
root.appendChild(animated);
|
|
|
|
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
|
const real =
|
|
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
|
|
return {
|
|
...real,
|
|
animationName: target === animated ? "fadeIn" : "none",
|
|
} as CSSStyleDeclaration;
|
|
});
|
|
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
|
{
|
|
currentTime: 0,
|
|
pause: () => {},
|
|
play: () => {},
|
|
effect: { getComputedTiming: () => ({ endTime: 6000 }) },
|
|
} as unknown as Animation,
|
|
];
|
|
|
|
window.__timelines = {};
|
|
|
|
initSandboxRuntimeModular();
|
|
|
|
expect(window.__renderReady).toBe(true);
|
|
expect(window.__player?.getDuration()).toBe(6);
|
|
});
|
|
|
|
it("still requires data-duration when a CSS animation is infinite (unbounded end time)", () => {
|
|
const root = document.createElement("div");
|
|
root.setAttribute("data-composition-id", "main");
|
|
root.setAttribute("data-root", "true");
|
|
root.setAttribute("data-start", "0");
|
|
root.setAttribute("data-width", "1920");
|
|
root.setAttribute("data-height", "1080");
|
|
document.body.appendChild(root);
|
|
|
|
const animated = document.createElement("div");
|
|
animated.style.animationName = "spin";
|
|
root.appendChild(animated);
|
|
|
|
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
|
const real =
|
|
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
|
|
return {
|
|
...real,
|
|
animationName: target === animated ? "spin" : "none",
|
|
} as CSSStyleDeclaration;
|
|
});
|
|
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
|
{
|
|
currentTime: 0,
|
|
pause: () => {},
|
|
play: () => {},
|
|
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
|
} as unknown as Animation,
|
|
];
|
|
|
|
window.__timelines = {};
|
|
|
|
initSandboxRuntimeModular();
|
|
|
|
// No data-duration, no GSAP timeline, and the only animation is
|
|
// unbounded — duration cannot be inferred, so it stays at 0. This is the
|
|
// case that must still surface the "add data-duration" lint/runtime error.
|
|
expect(window.__player?.getDuration()).toBe(0);
|
|
});
|
|
|
|
it("infers hf.duration from a registered Lottie animation without data-duration or a GSAP timeline", () => {
|
|
const root = document.createElement("div");
|
|
root.setAttribute("data-composition-id", "main");
|
|
root.setAttribute("data-root", "true");
|
|
root.setAttribute("data-start", "0");
|
|
root.setAttribute("data-width", "1920");
|
|
root.setAttribute("data-height", "1080");
|
|
document.body.appendChild(root);
|
|
|
|
(window as Window & { __hfLottie?: unknown[] }).__hfLottie = [
|
|
{ play: () => {}, pause: () => {}, totalFrames: 150, frameRate: 30 },
|
|
];
|
|
|
|
window.__timelines = {};
|
|
|
|
initSandboxRuntimeModular();
|
|
|
|
expect(window.__renderReady).toBe(true);
|
|
expect(window.__player?.getDuration()).toBe(5);
|
|
|
|
delete (window as Window & { __hfLottie?: unknown[] }).__hfLottie;
|
|
});
|
|
|
|
it("regression: a GSAP timeline's duration is unaffected by adapter duration inference", () => {
|
|
// A GSAP composition can legitimately have an incidental, short CSS
|
|
// animation running alongside the timeline (e.g. a decorative shimmer).
|
|
// The GSAP timeline must remain the source of truth for total duration —
|
|
// the new adapter-inference floor (resolveAdapterDurationFloorSeconds)
|
|
// must not shrink or otherwise override it.
|
|
const root = document.createElement("div");
|
|
root.setAttribute("data-composition-id", "root");
|
|
root.setAttribute("data-root", "true");
|
|
root.setAttribute("data-start", "0");
|
|
root.setAttribute("data-width", "1920");
|
|
root.setAttribute("data-height", "1080");
|
|
document.body.appendChild(root);
|
|
|
|
const shimmer = document.createElement("div");
|
|
shimmer.style.animationName = "shimmer";
|
|
root.appendChild(shimmer);
|
|
|
|
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
|
return {
|
|
animationName: target === shimmer ? "shimmer" : "none",
|
|
} as CSSStyleDeclaration;
|
|
});
|
|
(shimmer as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
|
{
|
|
currentTime: 0,
|
|
pause: () => {},
|
|
play: () => {},
|
|
// Much shorter than the GSAP timeline below (2s vs 12s) — must not
|
|
// become the reported duration.
|
|
effect: { getComputedTiming: () => ({ endTime: 2000 }) },
|
|
} as unknown as Animation,
|
|
];
|
|
|
|
window.__timelines = { root: createMockTimeline(12) };
|
|
|
|
initSandboxRuntimeModular();
|
|
|
|
expect(window.__renderReady).toBe(true);
|
|
expect(window.__player?.getDuration()).toBe(12);
|
|
});
|
|
|
|
it("seeks captured timeline to currentTime on initial bind", () => {
|
|
const seekTimes: number[] = [];
|
|
const tl = createMockTimeline(5);
|
|
const origTotalTime = tl.totalTime;
|
|
tl.totalTime = ((time: number, ...rest: unknown[]) => {
|
|
seekTimes.push(time);
|
|
(origTotalTime as Function).call(tl, time, ...rest);
|
|
}) as RuntimeTimelineLike["totalTime"];
|
|
|
|
document.body.innerHTML = `
|
|
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
|
|
`;
|
|
window.__timelines = { root: tl };
|
|
initSandboxRuntimeModular();
|
|
|
|
expect(seekTimes.length).toBeGreaterThanOrEqual(2);
|
|
expect(seekTimes[seekTimes.length - 1]).toBe(0);
|
|
});
|
|
|
|
it("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("");
|
|
});
|
|
});
|
|
});
|