diff --git a/skills-manifest.json b/skills-manifest.json index 84ceb7df0..c2a8e5f6a 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 17 }, "hyperframes-animation": { - "hash": "2ce5ca7dbf361e27", + "hash": "4418a24093c4b1c1", "files": 121 }, "hyperframes-audio": { diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index d26b2065b..0bb3c81bc 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -104,7 +104,11 @@ try { (_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3), ); - const bboxes = await sampleTweenBboxes(session.page, tw.selectorHint, times); + // No selector means no element to measure (an onUpdate driver). Sampling anyway + // would hand querySelector an unmatchable string. + const bboxes = tw.selectorHint + ? await sampleTweenBboxes(session.page, tw.selectorHint, times) + : []; const animProps = tw.props.filter( (p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p), @@ -114,7 +118,8 @@ try { report.tweens.push({ index: i + 1, - selector: tw.selectorHint, + selector: tw.selectorHint ?? "(onUpdate driver)", + driver: tw.driver, targets: tw.targetCount, props: animProps, start: +tw.start.toFixed(3), @@ -138,8 +143,14 @@ try { // ── Composition-level analysis ── report.choreography = buildTimeline(report.tweens, duration); report.density = computeDensity(report.tweens, duration); - report.staggers = detectStaggers(report.tweens); - report.elements = buildElementLifecycles(report.tweens); + // Staggers and lifecycles are per-ELEMENT, and a driver tween has none. Keyed on + // tw.selector they would collapse every driver in the composition into one + // "(onUpdate driver)" pseudo-element with null geometry, and let three same-duration + // drivers read as a stagger no element performs. Density, dead zones and the timeline + // still count them — those are per-SPAN, which is what a driver does have. + const elementTweens = report.tweens.filter((tw) => tw.driver !== "onUpdate"); + report.staggers = detectStaggers(elementTweens); + report.elements = buildElementLifecycles(elementTweens); report.deadZones = findDeadZones(report.density, duration); report.snapshots = await captureSnapshots(session, report.tweens, duration); @@ -184,17 +195,21 @@ async function enumerateTweens(session) { return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(); }; - const walk = (node, parentOffset = 0) => { + const walk = (node, parentOffset = 0, parentDriven = false) => { if (!node) return; if (typeof node.getChildren === "function") { const offset = parentOffset + (node.startTime?.() ?? 0); + // A TIMELINE can own the driver instead of the tween. The WebGL/uniform idiom is + // gsap.timeline({ onUpdate: renderFrame }) over children that tween plain uniform + // objects; those children carry no onUpdate of their own, so the driver has to + // reach them from above or their motion reads as a dead zone all the same. + const driven = parentDriven || typeof node.vars?.onUpdate === "function"; for (const child of node.getChildren(true, true, true)) { - walk(child, offset); + walk(child, offset, driven); } return; } const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element); - if (!targets.length) return; const vars = node.vars ?? {}; const props = Object.keys(vars).filter( (k) => @@ -210,10 +225,28 @@ async function enumerateTweens(session) { "stagger", ].includes(k), ); + // The proxy-driver idiom tweens a plain object and applies the motion in onUpdate, + // so targets() holds no Element. Dropping those tweens hid real motion from the + // map: computeDensity saw zero active tweens over their span and findDeadZones + // reported it as dead. There is no element to select or measure here, but the span + // is real, so keep the tween and mark why it carries no geometry. + // + // Under an inherited driver the tween must also CHANGE something. Its own onUpdate is + // proof of work by itself (a repaint loop need not animate a property), but a parent's + // is not: a bare `tl.to({}, { duration: D })` spacer inside a driven timeline advances + // the playhead without altering any value, so counting it would mask a genuine dead + // zone — the exact false positive the tween-local rule was careful to avoid. + const isProxyDriver = + targets.length === 0 && + (typeof vars.onUpdate === "function" || (parentDriven && props.length > 0)); + if (!targets.length && !isProxyDriver) return; const start = parentOffset + (node.startTime?.() ?? 0); const end = start + (node.duration?.() ?? 0); results.push({ - selectorHint: selectorOf(targets[0]) ?? "(unknown)", + // null, not a placeholder string: this feeds document.querySelector downstream, + // so it must be absent rather than unmatchable. + selectorHint: isProxyDriver ? null : (selectorOf(targets[0]) ?? "(unknown)"), + driver: isProxyDriver ? "onUpdate" : "target", targetCount: targets.length, props, start, @@ -234,7 +267,15 @@ function describeTween(tw, props, bboxes, flags) { const dur = (tw.end - tw.start).toFixed(2); const parts = []; - parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); + if (tw.selectorHint) { + parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); + } else { + // An onUpdate driver: the span and props are known, the affected element is not. + parts.push( + `an onUpdate driver animates ${props.join("+")} over ${dur}s (${tw.ease}) — ` + + `motion is applied in JS, so no element geometry was measured`, + ); + } // Movement const first = bboxes[0]; @@ -299,7 +340,10 @@ function computeFlags(tw, bboxes, { width, height }) { const flags = []; const dur = tw.end - tw.start; - if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); + // No samples at all (an onUpdate driver has no element to measure) is not evidence of + // a degenerate or invisible box — `[].every()` is vacuously true, so guard the + // geometry-derived flags. The pacing flags below read only start/end and still apply. + if (bboxes.length && bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); const anyOffscreen = bboxes.some( (b) => @@ -314,7 +358,10 @@ function computeFlags(tw, bboxes, { width, height }) { ); if (anyOffscreen) flags.push("offscreen"); - if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) { + if ( + bboxes.length && + bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible) + ) { flags.push("invisible"); } diff --git a/skills/hyperframes-animation/scripts/animation-map.test.mjs b/skills/hyperframes-animation/scripts/animation-map.test.mjs index f3cbbecdf..d93c33f66 100644 --- a/skills/hyperframes-animation/scripts/animation-map.test.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.test.mjs @@ -286,3 +286,159 @@ describe("transient-init retry", () => { } }); }); + +// ── Proxy-driver tweens (the false dead-zone fix) ─────────────────────────── +// The proxy-driver idiom tweens a plain object and applies the motion inside +// onUpdate, so the tween's targets() holds no Element. The map used to drop those +// tweens outright, which meant computeDensity counted zero active tweens over their +// span and findDeadZones reported real motion as a dead zone. +// +// The fake producer hands animation-map a session whose page.evaluate runs the +// callback in this process, against a stubbed window/document. That exercises the real +// enumerateTweens/computeDensity/findDeadZones code without a browser. +const FAKE_PROXY_DRIVER_ENV = [ + "globalThis.Element = class Element {};", + "const mover = new globalThis.Element();", + 'mover.id = "mover";', + "mover.classList = [];", + // 0-1s: an ordinary element tween. + "const elementTween = {", + " targets: () => [mover],", + ' vars: { x: 900, duration: 1, ease: "power2.out" },', + " startTime: () => 0,", + " duration: () => 1,", + "};", + // 2-4s: a proxy driver. Real motion, no Element target. + "const proxyTween = {", + " targets: () => [{ v: 0 }],", + ' vars: { v: 100, duration: 2, ease: "none", onUpdate() {} },', + " startTime: () => 2,", + " duration: () => 2,", + "};", + // 2-4s as well: a bare spacer with no onUpdate. Produces nothing, must stay dropped, + // otherwise every full-span anchor tween would mask genuine dead zones. + "const spacerTween = {", + " targets: () => [{}],", + " vars: { duration: 2 },", + " startTime: () => 2,", + " duration: () => 2,", + "};", + "const timeline = {", + " getChildren: () => [elementTween, proxyTween, spacerTween],", + " startTime: () => 0,", + " duration: () => 4,", + " seek() {},", + "};", + "globalThis.window = { __timelines: { main: timeline } };", + "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };", + "globalThis.getComputedStyle = () => ({", + ' opacity: "1",', + ' visibility: "visible",', + ' display: "block",', + "});", + 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + "export async function createCaptureSession() {", + " return { page: { evaluate: async (fn, arg) => fn(arg) } };", + "}", + "export async function closeCaptureSession() {}", + "export async function initializeSession() {}", + "export async function getCompositionDuration() { return 4; }", +].join("\n"); + +// The WebGL/uniform shape, e.g. skills/music-to-video/references/templates/ +// held-message-living-field: the TIMELINE carries onUpdate: renderFrame and its children +// tween plain uniform objects. No child has an onUpdate of its own, so a tween-local +// discriminator misses all of them and the whole composition reads as one dead zone. +const FAKE_PARENT_DRIVER_ENV = [ + "globalThis.Element = class Element {};", + "const uniformTween = {", + " targets: () => [{ value: 0 }],", + ' vars: { value: 12, duration: 12, ease: "none" },', + " startTime: () => 0,", + " duration: () => 12,", + "};", + // Same driven timeline, but this one alters nothing — the repaint it triggers is + // identical frame to frame, so it must NOT count as motion. + "const spacerTween = {", + " targets: () => [{}],", + " vars: { duration: 12 },", + " startTime: () => 0,", + " duration: () => 12,", + "};", + "const timeline = {", + " vars: { onUpdate() {} },", + " getChildren: () => [uniformTween, spacerTween],", + " startTime: () => 0,", + " duration: () => 12,", + " seek() {},", + "};", + "globalThis.window = { __timelines: { main: timeline } };", + "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };", + "globalThis.getComputedStyle = () => ({", + ' opacity: "1",', + ' visibility: "visible",', + ' display: "block",', + "});", + 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + "export async function createCaptureSession() {", + " return { page: { evaluate: async (fn, arg) => fn(arg) } };", + "}", + "export async function closeCaptureSession() {}", + "export async function initializeSession() {}", + "export async function getCompositionDuration() { return 12; }", +].join("\n"); + +describe("proxy-driver tweens", () => { + it("counts an onUpdate driver's span instead of reporting it as a dead zone", () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-proxy-test-")); + try { + const compositionDir = writeFakeEnv(root, FAKE_PROXY_DRIVER_ENV); + const output = runHelper(HELPERS[0], root, compositionDir); + const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8")); + + const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate"); + assert.equal(drivers.length, 1, `expected one onUpdate driver in:\n${output}`); + assert.equal(drivers[0].start, 2); + assert.equal(drivers[0].end, 4); + assert.equal(drivers[0].targets, 0); + assert.deepEqual(drivers[0].bboxes, [], "there is no element to measure"); + // `[].every()` is vacuously true, so unmeasured must not read as degenerate/invisible. + assert.deepEqual(drivers[0].flags, []); + + assert.deepEqual(report.deadZones, [], "2-4s is animating, not dead"); + // The bare spacer stays out — only the element tween and the driver are mapped. + assert.equal(report.tweens.length, 2); + + // Per-ELEMENT analyses must not adopt the driver as a pseudo-element. + assert.deepEqual(Object.keys(report.elements), ["#mover"]); + assert.deepEqual(report.staggers, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("inherits a driver the TIMELINE owns, without counting a spacer under it", () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-parent-driver-test-")); + try { + const compositionDir = writeFakeEnv(root, FAKE_PARENT_DRIVER_ENV); + const output = runHelper(HELPERS[0], root, compositionDir); + const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8")); + + const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate"); + assert.equal(drivers.length, 1, `expected one inherited driver in:\n${output}`); + assert.deepEqual(drivers[0].props, ["value"]); + assert.equal(drivers[0].start, 0); + assert.equal(drivers[0].end, 12); + + assert.deepEqual(report.deadZones, [], "the uniform tween animates the whole span"); + // Nothing element-backed here at all, so both per-element analyses stay empty. + assert.deepEqual(report.elements, {}); + assert.deepEqual(report.staggers, []); + // The spacer changes no value, so the parent's onUpdate repaints an identical frame. + // Counting it would mask a real dead zone. + assert.equal(report.tweens.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});