From d5988887e7c08fc488e6220d72739d7a7999c0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 10:59:52 +0000 Subject: [PATCH 1/4] fix(skills): pass rational fps to capture helpers --- .../scripts/animation-map.mjs | 2 +- .../scripts/animation-map.test.mjs | 66 +++++++++++++++++++ .../scripts/contrast-report.mjs | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 skills/hyperframes-animation/scripts/animation-map.test.mjs diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index f95bb09331..a9a3612df7 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -51,7 +51,7 @@ const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); const session = await createCaptureSession( server.url, OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + { width: WIDTH, height: HEIGHT, fps: { num: FPS, den: 1 }, format: "png" }, null, ); await initializeSession(session); diff --git a/skills/hyperframes-animation/scripts/animation-map.test.mjs b/skills/hyperframes-animation/scripts/animation-map.test.mjs new file mode 100644 index 0000000000..5ffd62e1e3 --- /dev/null +++ b/skills/hyperframes-animation/scripts/animation-map.test.mjs @@ -0,0 +1,66 @@ +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)} passes a rational frame rate`, () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-")); + const packageDir = join(root, "node_modules", "@hyperframes", "producer"); + const sharpPackageDir = join(root, "node_modules", "sharp"); + const compositionDir = join(root, "composition"); + mkdirSync(packageDir, { 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(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", "24", "--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":24,"den":1\}/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index 702490c149..c054608dd9 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -82,7 +82,7 @@ const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); const session = await createCaptureSession( server.url, OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + { width: WIDTH, height: HEIGHT, fps: { num: FPS, den: 1 }, format: "png" }, null, ); await initializeSession(session); From a2c3b043ba9c37d23dff660c255f86e002cfcc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 13:36:48 +0000 Subject: [PATCH 2/4] fix(skills): batch animation map sampling --- .../scripts/animation-map-sampling.mjs | 40 ++++++++++++++++ .../scripts/animation-map-sampling.test.mjs | 48 +++++++++++++++++++ .../scripts/animation-map.mjs | 25 +--------- 3 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 skills/hyperframes-animation/scripts/animation-map-sampling.mjs create mode 100644 skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs diff --git a/skills/hyperframes-animation/scripts/animation-map-sampling.mjs b/skills/hyperframes-animation/scripts/animation-map-sampling.mjs new file mode 100644 index 0000000000..7a118c6f69 --- /dev/null +++ b/skills/hyperframes-animation/scripts/animation-map-sampling.mjs @@ -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 }, + ); +} diff --git a/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs b/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs new file mode 100644 index 0000000000..ef6ddb8b7e --- /dev/null +++ b/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs @@ -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; + } +}); diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index a9a3612df7..56ee2d31f5 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -16,6 +16,7 @@ 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 { @@ -77,12 +78,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 +201,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) { From 2d5114b2eaf5247c7366bd05f9bc151d6259e415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 06:10:50 +0000 Subject: [PATCH 3/4] chore(skills): refresh animation manifests --- skills-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 868882e205..97486def99 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,8 +22,8 @@ "files": 1 }, "hyperframes-animation": { - "hash": "b982a1af9821e326", - "files": 99 + "hash": "41efe98e303811d8", + "files": 102 }, "hyperframes-cli": { "hash": "5e1e197baf9b6653", @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "826e4eafaedf8936", + "hash": "11ce270933f33f3e", "files": 78 }, "hyperframes-keyframes": { From 49d8d8ea54e9cb2a82b08fafefe63240542c8f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 21:16:25 +0000 Subject: [PATCH 4/4] fix(skills): parse exact animation map frame rates --- skills-manifest.json | 4 +- .../scripts/animation-map.mjs | 19 ++++++---- .../scripts/animation-map.test.mjs | 38 +++++++++++++++++-- .../scripts/contrast-report.mjs | 20 +++++++--- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 97486def99..cf4f6a599f 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "41efe98e303811d8", + "hash": "40a79102f708b4f5", "files": 102 }, "hyperframes-cli": { @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "11ce270933f33f3e", + "hash": "a508ecd60aaa6dbf", "files": 78 }, "hyperframes-keyframes": { diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index 56ee2d31f5..d92cd2979f 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -19,17 +19,20 @@ 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 ───────────────────────────────────────────────────────────────────── @@ -41,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 }); @@ -52,7 +57,7 @@ const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); const session = await createCaptureSession( server.url, OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: { num: FPS, den: 1 }, format: "png" }, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, null, ); await initializeSession(session); diff --git a/skills/hyperframes-animation/scripts/animation-map.test.mjs b/skills/hyperframes-animation/scripts/animation-map.test.mjs index 5ffd62e1e3..ae2cb3ad1b 100644 --- a/skills/hyperframes-animation/scripts/animation-map.test.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.test.mjs @@ -14,12 +14,14 @@ const HELPERS = [ describe("HyperFrames skill helpers", () => { for (const helper of HELPERS) - it(`${helper.split("/").at(-1)} passes a rational frame rate`, () => { + 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( @@ -38,6 +40,20 @@ describe("HyperFrames skill helpers", () => { "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" }), @@ -47,7 +63,7 @@ describe("HyperFrames skill helpers", () => { try { const result = spawnSync( process.execPath, - [helper, compositionDir, "--fps", "24", "--out", join(root, "output")], + [helper, compositionDir, "--fps", "30000/1001", "--out", join(root, "output")], { encoding: "utf8", env: { @@ -58,7 +74,23 @@ describe("HyperFrames skill helpers", () => { ); const output = `${result.stdout}\n${result.stderr}`; assert.notEqual(result.status, 0); - assert.match(output, /CAPTURE_OPTIONS=.*"fps":\{"num":24,"den":1\}/); + 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 }); } diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index c054608dd9..a1b5b6a8a8 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -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 ──────────────────────────────────────────────────────────────────── @@ -82,7 +92,7 @@ const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); const session = await createCaptureSession( server.url, OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: { num: FPS, den: 1 }, format: "png" }, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, null, ); await initializeSession(session);