Setting the file. One moment. Animation Map Test · Hyperframes Animation · heygen-com/hyperframes · Skills DocsBlueprints Index
scripts/animation-map.test.mjs
scripts/animation-map.test.mjs
JavaScript·444 lines·19 KB
from
"node:test"
;
8
9const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
10const HELPERS = [
11 join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "animation-map.mjs"),
12 join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "contrast-report.mjs"),
13];
14
15describe("HyperFrames skill helpers", () => {
16 for (const helper of HELPERS)
17 it(`${helper.split("/").at(-1)} bundles modular input and uses rational fps`, () => {
18 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-"));
19 const packageDir = join(root, "node_modules", "@hyperframes", "producer");
20 const corePackageDir = join(root, "node_modules", "@hyperframes", "core");
21 const sharpPackageDir = join(root, "node_modules", "sharp");
22 const compositionDir = join(root, "composition");
23 mkdirSync(packageDir, { recursive: true });
24 mkdirSync(corePackageDir, { recursive: true });
25 mkdirSync(sharpPackageDir, { recursive: true });
26 mkdirSync(compositionDir, { recursive: true });
27 writeFileSync(
28 join(packageDir, "package.json"),
29 JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }),
30 );
31 writeFileSync(
32 join(packageDir, "index.mjs"),
33 [
34 'import { readFileSync } from "node:fs";',
35 'import { join } from "node:path";',
36 "export async function createFileServer(options) {",
37 ' const bundled = readFileSync(join(options.compiledDir, "index.html"), "utf8");',
38 ' if (bundled !== "<!doctype html><main>bundled modular composition</main>") {',
39 " throw new Error(`UNEXPECTED_BUNDLE=${bundled}`);",
40 " }",
41 ' return { url: "http://test", close() {} };',
42 "}",
43 "export async function createCaptureSession(_url, _out, options) {",
44 " throw new Error(`CAPTURE_OPTIONS=${JSON.stringify(options)}`);",
45 "}",
46 "export async function initializeSession() {}",
47 "export async function closeCaptureSession() {}",
48 "export async function getCompositionDuration() { return 0; }",
49 ].join("\n"),
50 );
51 writeFileSync(
52 join(corePackageDir, "package.json"),
53 JSON.stringify({
54 name: "@hyperframes/core",
55 type: "module",
56 exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" },
57 }),
58 );
59 writeFileSync(
60 join(corePackageDir, "index.mjs"),
61 [
62 "export function parseFps(input) {",
63 " if (input === '30000/1001') return { ok: true, value: { num: 30000, den: 1001 } };",
64 " if (input === '29.97') return { ok: false, reason: 'ambiguous-decimal' };",
65 " return { ok: true, value: { num: Number(input), den: 1 } };",
66 "}",
67 ].join("\n"),
68 );
69 writeFileSync(
70 join(corePackageDir, "compiler.mjs"),
71 [
72 "export async function bundleToSingleHtml() {",
73 ' return "<!doctype html><main>bundled modular composition</main>";',
74 "}",
75 ].join("\n"),
76 );
77 writeFileSync(
78 join(sharpPackageDir, "package.json"),
79 JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }),
80 );
81 writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n");
82
83 try {
84 const result = spawnSync(
85 process.execPath,
86 [helper, compositionDir, "--fps", "30000/1001", "--out", join(root, "output")],
87 {
88 encoding: "utf8",
89 env: {
90 ...process.env,
91 HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
92 },
93 },
94 );
95 const output = `${result.stdout}\n${result.stderr}`;
96 assert.notEqual(result.status, 0);
97 assert.match(output, /CAPTURE_OPTIONS=.*"fps":\{"num":30000,"den":1001\}/);
98
99 const invalid = spawnSync(
100 process.execPath,
101 [helper, compositionDir, "--fps", "29.97", "--out", join(root, "invalid-output")],
102 {
103 encoding: "utf8",
104 env: {
105 ...process.env,
106 HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
107 },
108 },
109 );
110 const invalidOutput = `${invalid.stdout}\n${invalid.stderr}`;
111 assert.notEqual(invalid.status, 0);
112 assert.match(invalidOutput, /Invalid --fps "29\.97": ambiguous-decimal/);
113 assert.doesNotMatch(invalidOutput, /CAPTURE_OPTIONS=/);
114 } finally {
115 rmSync(root, { recursive: true, force: true });
116 }
117 });
118});
119
120// The two package-loader.mjs copies are intentionally byte-identical (each
121// skill ships standalone, so neither can import the other's) and now carry
122// shared logic (initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS)
123// that a future fix could land in one copy and silently miss in the other —
124// the exact drift class the audio.mjs identity pin was born to catch.
125describe("package-loader parity", () => {
126 it("package-loader.mjs is byte-identical to hyperframes-creative's copy (the stated contract)", () => {
127 const here = readFileSync(
128 join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "package-loader.mjs"),
129 "utf8",
130 );
131 const sibling = readFileSync(
132 join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "package-loader.mjs"),
133 "utf8",
134 );
135 assert.equal(here, sibling);
136 });
137});
138
139// ── Transient-init retry (the zero-duration false-fail fix) ─────────────────
140// A valid modular project's sub-composition timelines register asynchronously;
141// the first initializeSession can time out with the transient "zero duration /
142// Runtime ready: false" diagnostic. The render pipeline closes the crashed
143// session and retries once with a fresh browser (probeStage) — the standalone
144// helpers must do the same instead of reporting the project as zero-duration.
145
146/** Write a fake node_modules with the given producer index.mjs source. */
147function writeFakeEnv(root, producerIndexSource) {
148 const packageDir = join(root, "node_modules", "@hyperframes", "producer");
149 const corePackageDir = join(root, "node_modules", "@hyperframes", "core");
150 const sharpPackageDir = join(root, "node_modules", "sharp");
151 const compositionDir = join(root, "composition");
152 mkdirSync(packageDir, { recursive: true });
153 mkdirSync(corePackageDir, { recursive: true });
154 mkdirSync(sharpPackageDir, { recursive: true });
155 mkdirSync(compositionDir, { recursive: true });
156 writeFileSync(
157 join(packageDir, "package.json"),
158 JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }),
159 );
160 writeFileSync(join(packageDir, "index.mjs"), producerIndexSource);
161 writeFileSync(
162 join(corePackageDir, "package.json"),
163 JSON.stringify({
164 name: "@hyperframes/core",
165 type: "module",
166 exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" },
167 }),
168 );
169 writeFileSync(
170 join(corePackageDir, "index.mjs"),
171 "export function parseFps(input) { return { ok: true, value: { num: Number(input), den: 1 } }; }",
172 );
173 writeFileSync(
174 join(corePackageDir, "compiler.mjs"),
175 'export async function bundleToSingleHtml() { return "<!doctype html><main>x</main>"; }',
176 );
177 writeFileSync(
178 join(sharpPackageDir, "package.json"),
179 JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }),
180 );
181 writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n");
182 return compositionDir;
183}
184
185function runHelper(helper, root, compositionDir) {
186 const result = spawnSync(process.execPath, [helper, compositionDir, "--out", join(root, "out")], {
187 encoding: "utf8",
188 env: { ...process.env, HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules") },
189 });
190 return `${result.stdout}\n${result.stderr}`;
191}
192
193const FAKE_PRODUCER_COMMON = [
194 'export async function createFileServer() { return { url: "http://test", close() {} }; }',
195 'export async function createCaptureSession() { console.error("SESSION_CREATED"); return {}; }',
196 'export async function closeCaptureSession() { console.error("SESSION_CLOSED"); }',
197 "export async function getCompositionDuration() { return 0; }",
198].join("\n");
199
200describe("transient-init retry", () => {
201 for (const helper of HELPERS) {
202 it(`${helper.split("/").at(-1)} retries a transient zero-duration init once with a fresh session`, () => {
203 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
204 try {
205 const compositionDir = writeFakeEnv(
206 root,
207 [
208 FAKE_PRODUCER_COMMON,
209 "let initCalls = 0;",
210 "export async function initializeSession() {",
211 " initCalls++;",
212 " if (initCalls === 1) {",
213 // The transient shape: readiness deadline hit before async
214 // sub-composition timelines landed (Runtime ready: false).
215 ' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: false");',
216 " }",
217 ' throw new Error("INIT_ATTEMPT_2_REACHED");',
218 "}",
219 ].join("\n"),
220 );
221
222 const output = runHelper(helper, root, compositionDir);
223
224 // Retried: fresh session created for attempt 2, crashed one closed.
225 assert.match(output, /retrying with a fresh browser session/);
226 assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 2);
227 assert.equal((output.match(/SESSION_CLOSED/g) ?? []).length, 2);
228 // ...and the retry genuinely re-ran init (bounded: no third attempt).
229 assert.match(output, /INIT_ATTEMPT_2_REACHED/);
230 } finally {
231 rmSync(root, { recursive: true, force: true });
232 }
233 });
234
235 it(`${helper.split("/").at(-1)} does NOT retry a genuine authoring failure (Runtime ready: true)`, () => {
236 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
237 try {
238 const compositionDir = writeFakeEnv(
239 root,
240 [
241 FAKE_PRODUCER_COMMON,
242 "export async function initializeSession() {",
243 // The fast-fail shape: runtime IS ready, there is genuinely no
244 // timeline/duration — an authoring bug retries can't fix.
245 ' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: true");',
246 "}",
247 ].join("\n"),
248 );
249
250 const output = runHelper(helper, root, compositionDir);
251
252 assert.doesNotMatch(output, /retrying with a fresh browser session/);
253 assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 1);
254 assert.match(output, /Composition has zero duration/);
255 } finally {
256 rmSync(root, { recursive: true, force: true });
257 }
258 });
259 }
260
261 it("prefers the producer's own isTransientBrowserError classifier when exported", () => {
262 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
263 try {
264 const compositionDir = writeFakeEnv(
265 root,
266 [
267 FAKE_PRODUCER_COMMON,
268 // A message the frozen fallback patterns would NOT match — only the
269 // producer-provided classifier can mark it transient.
270 "export function isTransientBrowserError(err) { return String(err && err.message).includes('CUSTOM_TRANSIENT'); }",
271 "let initCalls = 0;",
272 "export async function initializeSession() {",
273 " initCalls++;",
274 ' if (initCalls === 1) throw new Error("CUSTOM_TRANSIENT flake");',
275 ' throw new Error("INIT_ATTEMPT_2_REACHED");',
276 "}",
277 ].join("\n"),
278 );
279
280 const output = runHelper(HELPERS[0], root, compositionDir);
281
282 assert.match(output, /retrying with a fresh browser session/);
283 assert.match(output, /INIT_ATTEMPT_2_REACHED/);
284 } finally {
285 rmSync(root, { recursive: true, force: true });
286 }
287 });
288});
289
290// ── Proxy-driver tweens (the false dead-zone fix) ───────────────────────────
291// The proxy-driver idiom tweens a plain object and applies the motion inside
292// onUpdate, so the tween's targets() holds no Element. The map used to drop those
293// tweens outright, which meant computeDensity counted zero active tweens over their
294// span and findDeadZones reported real motion as a dead zone.
295//
296// The fake producer hands animation-map a session whose page.evaluate runs the
297// callback in this process, against a stubbed window/document. That exercises the real
298// enumerateTweens/computeDensity/findDeadZones code without a browser.
299const FAKE_PROXY_DRIVER_ENV = [
300 "globalThis.Element = class Element {};",
301 "const mover = new globalThis.Element();",
302 'mover.id = "mover";',
303 "mover.classList = [];",
304 // 0-1s: an ordinary element tween.
305 "const elementTween = {",
306 " targets: () => [mover],",
307 ' vars: { x: 900, duration: 1, ease: "power2.out" },',
308 " startTime: () => 0,",
309 " duration: () => 1,",
310 "};",
311 // 2-4s: a proxy driver. Real motion, no Element target.
312 "const proxyTween = {",
313 " targets: () => [{ v: 0 }],",
314 ' vars: { v: 100, duration: 2, ease: "none", onUpdate() {} },',
315 " startTime: () => 2,",
316 " duration: () => 2,",
317 "};",
318 // 2-4s as well: a bare spacer with no onUpdate. Produces nothing, must stay dropped,
319 // otherwise every full-span anchor tween would mask genuine dead zones.
320 "const spacerTween = {",
321 " targets: () => [{}],",
322 " vars: { duration: 2 },",
323 " startTime: () => 2,",
324 " duration: () => 2,",
325 "};",
326 "const timeline = {",
327 " getChildren: () => [elementTween, proxyTween, spacerTween],",
328 " startTime: () => 0,",
329 " duration: () => 4,",
330 " seek() {},",
331 "};",
332 "globalThis.window = { __timelines: { main: timeline } };",
333 "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };",
334 "globalThis.getComputedStyle = () => ({",
335 ' opacity: "1",',
336 ' visibility: "visible",',
337 ' display: "block",',
338 "});",
339 'export async function createFileServer() { return { url: "http://test", close() {} }; }',
340 "export async function createCaptureSession() {",
341 " return { page: { evaluate: async (fn, arg) => fn(arg) } };",
342 "}",
343 "export async function closeCaptureSession() {}",
344 "export async function initializeSession() {}",
345 "export async function getCompositionDuration() { return 4; }",
346].join("\n");
347
348// The WebGL/uniform shape, e.g. skills/music-to-video/references/templates/
349// held-message-living-field: the TIMELINE carries onUpdate: renderFrame and its children
350// tween plain uniform objects. No child has an onUpdate of its own, so a tween-local
351// discriminator misses all of them and the whole composition reads as one dead zone.
352const FAKE_PARENT_DRIVER_ENV = [
353 "globalThis.Element = class Element {};",
354 "const uniformTween = {",
355 " targets: () => [{ value: 0 }],",
356 ' vars: { value: 12, duration: 12, ease: "none" },',
357 " startTime: () => 0,",
358 " duration: () => 12,",
359 "};",
360 // Same driven timeline, but this one alters nothing — the repaint it triggers is
361 // identical frame to frame, so it must NOT count as motion.
362 "const spacerTween = {",
363 " targets: () => [{}],",
364 " vars: { duration: 12 },",
365 " startTime: () => 0,",
366 " duration: () => 12,",
367 "};",
368 "const timeline = {",
369 " vars: { onUpdate() {} },",
370 " getChildren: () => [uniformTween, spacerTween],",
371 " startTime: () => 0,",
372 " duration: () => 12,",
373 " seek() {},",
374 "};",
375 "globalThis.window = { __timelines: { main: timeline } };",
376 "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };",
377 "globalThis.getComputedStyle = () => ({",
378 ' opacity: "1",',
379 ' visibility: "visible",',
380 ' display: "block",',
381 "});",
382 'export async function createFileServer() { return { url: "http://test", close() {} }; }',
383 "export async function createCaptureSession() {",
384 " return { page: { evaluate: async (fn, arg) => fn(arg) } };",
385 "}",
386 "export async function closeCaptureSession() {}",
387 "export async function initializeSession() {}",
388 "export async function getCompositionDuration() { return 12; }",
389].join("\n");
390
391describe("proxy-driver tweens", () => {
392 it("counts an onUpdate driver's span instead of reporting it as a dead zone", () => {
393 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-proxy-test-"));
394 try {
395 const compositionDir = writeFakeEnv(root, FAKE_PROXY_DRIVER_ENV);
396 const output = runHelper(HELPERS[0], root, compositionDir);
397 const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8"));
398
399 const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate");
400 assert.equal(drivers.length, 1, `expected one onUpdate driver in:\n${output}`);
401 assert.equal(drivers[0].start, 2);
402 assert.equal(drivers[0].end, 4);
403 assert.equal(drivers[0].targets, 0);
404 assert.deepEqual(drivers[0].bboxes, [], "there is no element to measure");
405 // `[].every()` is vacuously true, so unmeasured must not read as degenerate/invisible.
406 assert.deepEqual(drivers[0].flags, []);
407
408 assert.deepEqual(report.deadZones, [], "2-4s is animating, not dead");
409 // The bare spacer stays out — only the element tween and the driver are mapped.
410 assert.equal(report.tweens.length, 2);
411
412 // Per-ELEMENT analyses must not adopt the driver as a pseudo-element.
413 assert.deepEqual(Object.keys(report.elements), ["#mover"]);
414 assert.deepEqual(report.staggers, []);
415 } finally {
416 rmSync(root, { recursive: true, force: true });
417 }
418 });
419
420 it("inherits a driver the TIMELINE owns, without counting a spacer under it", () => {
421 const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-parent-driver-test-"));
422 try {
423 const compositionDir = writeFakeEnv(root, FAKE_PARENT_DRIVER_ENV);
424 const output = runHelper(HELPERS[0], root, compositionDir);
425 const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8"));
426
427 const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate");
428 assert.equal(drivers.length, 1, `expected one inherited driver in:\n${output}`);
429 assert.deepEqual(drivers[0].props, ["value"]);
430 assert.equal(drivers[0].start, 0);
431 assert.equal(drivers[0].end, 12);
432
433 assert.deepEqual(report.deadZones, [], "the uniform tween animates the whole span");
434 // Nothing element-backed here at all, so both per-element analyses stay empty.
435 assert.deepEqual(report.elements, {});
436 assert.deepEqual(report.staggers, []);
437 // The spacer changes no value, so the parent's onUpdate repaints an identical frame.
438 // Counting it would mask a real dead zone.
439 assert.equal(report.tweens.length, 1);
440 } finally {
441 rmSync(root, { recursive: true, force: true });
442 }
443 });
444});