fix(skills): consolidate animation-map capture reliability (#2409)

* fix(skills): pass rational fps to capture helpers

* fix(skills): batch animation map sampling

* chore(skills): refresh animation manifests

* fix(skills): parse exact animation map frame rates
This commit is contained in:
Miguel Ángel
2026-07-14 18:04:32 -04:00
committed by GitHub
parent 9ce44b6603
commit eb731b6a8a
6 changed files with 216 additions and 36 deletions
+3 -3
View File
@@ -22,8 +22,8 @@
"files": 1
},
"hyperframes-animation": {
"hash": "b982a1af9821e326",
"files": 99
"hash": "40a79102f708b4f5",
"files": 102
},
"hyperframes-cli": {
"hash": "f78d0c936fd3499f",
@@ -34,7 +34,7 @@
"files": 14
},
"hyperframes-creative": {
"hash": "826e4eafaedf8936",
"hash": "a508ecd60aaa6dbf",
"files": 78
},
"hyperframes-keyframes": {
@@ -0,0 +1,40 @@
/**
* Seek and measure every sample for one tween inside a single browser
* evaluation. GSAP/HyperFrames seeks update DOM state synchronously, so a
* separate CDP round trip and wall-clock sleep per sample only adds latency.
*/
export async function sampleTweenBboxes(page, selector, times) {
return page.evaluate(
({ selector: sel, times: sampleTimes }) => {
const seek = (time) => {
if (window.__hf && typeof window.__hf.seek === "function") {
window.__hf.seek(time);
return;
}
const timelines = window.__timelines;
if (!timelines) return;
for (const timeline of Object.values(timelines)) {
if (typeof timeline.seek === "function") timeline.seek(time);
}
};
return sampleTimes.map((time) => {
seek(time);
const el = document.querySelector(sel);
if (!el) return { t: time, x: 0, y: 0, w: 0, h: 0, missing: true };
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
t: time,
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height),
opacity: parseFloat(style.opacity),
visible: style.visibility !== "hidden" && style.display !== "none",
};
});
},
{ selector, times },
);
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sampleTweenBboxes } from "./animation-map-sampling.mjs";
test("samples every tween time in one browser evaluation", async () => {
const calls = [];
const seekTimes = [];
const originalGlobals = {
window: globalThis.window,
document: globalThis.document,
getComputedStyle: globalThis.getComputedStyle,
};
let currentTime = 0;
globalThis.window = { __hf: { seek: (time) => (currentTime = time) } };
globalThis.document = {
querySelector: () => ({
getBoundingClientRect: () => ({ x: currentTime, y: 20, width: 30, height: 40 }),
}),
};
globalThis.getComputedStyle = () => ({ opacity: "1", visibility: "visible", display: "block" });
const page = {
async evaluate(callback, payload) {
calls.push(payload);
const originalSeek = globalThis.window.__hf.seek;
globalThis.window.__hf.seek = (time) => {
seekTimes.push(time);
originalSeek(time);
};
return callback(payload);
},
};
try {
const result = await sampleTweenBboxes(page, "#card", [1, 2, 3]);
assert.deepEqual(result, [
{ t: 1, x: 1, y: 20, w: 30, h: 40, opacity: 1, visible: true },
{ t: 2, x: 2, y: 20, w: 30, h: 40, opacity: 1, visible: true },
{ t: 3, x: 3, y: 20, w: 30, h: 40, opacity: 1, visible: true },
]);
assert.deepEqual(seekTimes, [1, 2, 3]);
assert.deepEqual(calls, [{ selector: "#card", times: [1, 2, 3] }]);
} finally {
globalThis.window = originalGlobals.window;
globalThis.document = originalGlobals.document;
globalThis.getComputedStyle = originalGlobals.getComputedStyle;
}
});
@@ -16,19 +16,23 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { sampleTweenBboxes } from "./animation-map-sampling.mjs";
import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs";
const packages = await importPackagesOrBootstrap(["@hyperframes/producer", "@hyperframes/core"], {
npmPackages: [
hyperframesPackageSpec("@hyperframes/producer"),
hyperframesPackageSpec("@hyperframes/core"),
],
});
const {
createFileServer,
createCaptureSession,
initializeSession,
closeCaptureSession,
getCompositionDuration,
} = (
await importPackagesOrBootstrap(["@hyperframes/producer"], {
npmPackages: [hyperframesPackageSpec("@hyperframes/producer")],
})
)["@hyperframes/producer"];
} = packages["@hyperframes/producer"];
const { parseFps } = packages["@hyperframes/core"];
// ─── CLI ─────────────────────────────────────────────────────────────────────
@@ -40,7 +44,9 @@ const OUT_DIR = resolve(args.out ?? ".hyperframes/anim-map");
const MIN_DUR = Number(args["min-duration"] ?? 0.15);
const WIDTH = Number(args.width ?? 1920);
const HEIGHT = Number(args.height ?? 1080);
const FPS = Number(args.fps ?? 30);
const parsedFps = parseFps(args.fps ?? 30);
if (!parsedFps.ok) die(`Invalid --fps "${args.fps ?? ""}": ${parsedFps.reason}`);
const FPS = parsedFps.value;
const COMP_DIR = resolve(args.composition);
await mkdir(OUT_DIR, { recursive: true });
@@ -77,12 +83,7 @@ try {
(_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3),
);
const bboxes = [];
for (const t of times) {
await seekTo(session, t);
const bbox = await measureTarget(session, tw.selectorHint);
bboxes.push({ t, ...bbox });
}
const bboxes = await sampleTweenBboxes(session.page, tw.selectorHint, times);
const animProps = tw.props.filter(
(p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p),
@@ -205,23 +206,6 @@ async function enumerateTweens(session) {
});
}
async function measureTarget(session, selector) {
return await session.page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return { x: 0, y: 0, w: 0, h: 0, missing: true };
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
opacity: parseFloat(cs.opacity),
visible: cs.visibility !== "hidden" && cs.display !== "none",
};
}, selector);
}
// ─── Tween description (the key output for agents) ──────────────────────────
function describeTween(tw, props, bboxes, flags) {
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it } from "node:test";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const HELPERS = [
join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "animation-map.mjs"),
join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "contrast-report.mjs"),
];
describe("HyperFrames skill helpers", () => {
for (const helper of HELPERS)
it(`${helper.split("/").at(-1)} uses canonical rational frame-rate parsing`, () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-"));
const packageDir = join(root, "node_modules", "@hyperframes", "producer");
const corePackageDir = join(root, "node_modules", "@hyperframes", "core");
const sharpPackageDir = join(root, "node_modules", "sharp");
const compositionDir = join(root, "composition");
mkdirSync(packageDir, { recursive: true });
mkdirSync(corePackageDir, { recursive: true });
mkdirSync(sharpPackageDir, { recursive: true });
mkdirSync(compositionDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }),
);
writeFileSync(
join(packageDir, "index.mjs"),
[
'export async function createFileServer() { return { url: "http://test", close() {} }; }',
"export async function createCaptureSession(_url, _out, options) {",
" throw new Error(`CAPTURE_OPTIONS=${JSON.stringify(options)}`);",
"}",
"export async function initializeSession() {}",
"export async function closeCaptureSession() {}",
"export async function getCompositionDuration() { return 0; }",
].join("\n"),
);
writeFileSync(
join(corePackageDir, "package.json"),
JSON.stringify({ name: "@hyperframes/core", type: "module", exports: "./index.mjs" }),
);
writeFileSync(
join(corePackageDir, "index.mjs"),
[
"export function parseFps(input) {",
" if (input === '30000/1001') return { ok: true, value: { num: 30000, den: 1001 } };",
" if (input === '29.97') return { ok: false, reason: 'ambiguous-decimal' };",
" return { ok: true, value: { num: Number(input), den: 1 } };",
"}",
].join("\n"),
);
writeFileSync(
join(sharpPackageDir, "package.json"),
JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }),
);
writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n");
try {
const result = spawnSync(
process.execPath,
[helper, compositionDir, "--fps", "30000/1001", "--out", join(root, "output")],
{
encoding: "utf8",
env: {
...process.env,
HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
},
},
);
const output = `${result.stdout}\n${result.stderr}`;
assert.notEqual(result.status, 0);
assert.match(output, /CAPTURE_OPTIONS=.*"fps":\{"num":30000,"den":1001\}/);
const invalid = spawnSync(
process.execPath,
[helper, compositionDir, "--fps", "29.97", "--out", join(root, "invalid-output")],
{
encoding: "utf8",
env: {
...process.env,
HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
},
},
);
const invalidOutput = `${invalid.stdout}\n${invalid.stderr}`;
assert.notEqual(invalid.status, 0);
assert.match(invalidOutput, /Invalid --fps "29\.97": ambiguous-decimal/);
assert.doesNotMatch(invalidOutput, /CAPTURE_OPTIONS=/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
@@ -49,10 +49,18 @@ import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loa
// Use the producer's file server — it auto-injects the HyperFrames runtime
// and render-seek bridge, so raw authoring HTML works without a build step.
const packages = await importPackagesOrBootstrap(["@hyperframes/producer", "sharp"], {
npmPackages: [hyperframesPackageSpec("@hyperframes/producer"), "sharp@0.34.5"],
});
const packages = await importPackagesOrBootstrap(
["@hyperframes/producer", "@hyperframes/core", "sharp"],
{
npmPackages: [
hyperframesPackageSpec("@hyperframes/producer"),
hyperframesPackageSpec("@hyperframes/core"),
"sharp@0.34.5",
],
},
);
const sharp = packages.sharp.default;
const { parseFps } = packages["@hyperframes/core"];
const {
createFileServer,
createCaptureSession,
@@ -71,7 +79,9 @@ const SAMPLES = Number(args.samples ?? 10);
const OUT_DIR = resolve(args.out ?? ".hyperframes/contrast");
const WIDTH = Number(args.width ?? 1920);
const HEIGHT = Number(args.height ?? 1080);
const FPS = Number(args.fps ?? 30);
const parsedFps = parseFps(args.fps ?? 30);
if (!parsedFps.ok) die(`Invalid --fps "${args.fps ?? ""}": ${parsedFps.reason}`);
const FPS = parsedFps.value;
const COMP_DIR = resolve(args.composition);
// ─── Main ────────────────────────────────────────────────────────────────────