From f542349b3059297c494f14023ede8a54ff8a19b1 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] 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 f95bb09331..1f88940de6 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) {