mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user