From df47df24535e6787541e1f57229019fdb2b2ccba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 19 Jun 2026 19:44:02 -0400 Subject: [PATCH 01/23] =?UTF-8?q?feat(cli):=20keyframes=20command=20?= =?UTF-8?q?=E2=80=94=20surface=20GSAP=20motion=20+=203D=20onion-skin=20--s?= =?UTF-8?q?hot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-then-edit-source tool for GSAP motion. Surfaces every tween's keyframes as data; --shot renders a true-3D onion-skin of the real element (samples the live timeline at N steps; rotation/scale/opacity/colour/3D, not just x/y) for visual self-verify. Multi-stroke pen-up traces for holed/detached shapes. Framing: --layout strip (filmstrip), --from/--to (time window), --no-fit, --angle (orbit camera). Pure geometry+SVG in keyframesShotLayout.ts (unit-tested). Ships the hyperframes-keyframes skill. Depends only on what's already in main. --- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/keyframes.test.ts | 48 ++ packages/cli/src/commands/keyframes.ts | 487 ++++++++++++++++++ packages/cli/src/commands/keyframesShot.ts | 292 +++++++++++ .../src/commands/keyframesShotLayout.test.ts | 139 +++++ .../cli/src/commands/keyframesShotLayout.ts | 208 ++++++++ skills/hyperframes-keyframes/SKILL.md | 110 ++++ .../references/editing-keyframes.md | 61 +++ .../references/gotchas.md | 35 ++ .../references/multi-stroke.md | 60 +++ .../references/reading-the-surface.md | 53 ++ 11 files changed, 1494 insertions(+) create mode 100644 packages/cli/src/commands/keyframes.test.ts create mode 100644 packages/cli/src/commands/keyframes.ts create mode 100644 packages/cli/src/commands/keyframesShot.ts create mode 100644 packages/cli/src/commands/keyframesShotLayout.test.ts create mode 100644 packages/cli/src/commands/keyframesShotLayout.ts create mode 100644 skills/hyperframes-keyframes/SKILL.md create mode 100644 skills/hyperframes-keyframes/references/editing-keyframes.md create mode 100644 skills/hyperframes-keyframes/references/gotchas.md create mode 100644 skills/hyperframes-keyframes/references/multi-stroke.md create mode 100644 skills/hyperframes-keyframes/references/reading-the-surface.md diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 23261f206b..7b1ad85818 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -121,6 +121,7 @@ const commandLoaders = { lint: () => import("./commands/lint.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), + keyframes: () => import("./commands/keyframes.js").then((m) => m.default), layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts new file mode 100644 index 0000000000..bb66418b09 --- /dev/null +++ b/packages/cli/src/commands/keyframes.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { ensureDOMParser } from "../utils/dom.js"; +import { surfaceComposition } from "./keyframes.js"; + +beforeAll(() => ensureDOMParser()); + +const wrap = (script: string) => + `
`; + +describe("keyframes multi-stroke traces", () => { + it("composites ≥2 position strokes on one element into a single trace", () => { + const html = wrap(` + const tl = gsap.timeline({ paused: true }); + tl.to("#dot", { keyframes: { "0%": { x: -100, y: -150 }, "100%": { x: 80, y: -120 } }, duration: 1 }); + tl.to("#dot", { keyframes: { "0%": { x: 80, y: 120 }, "100%": { x: 85, y: 140 } }, duration: 1 }); + window.__timelines = [tl]; + `); + const { traces } = surfaceComposition(html, "index.html", "index.html"); + expect(traces).toHaveLength(1); + expect(traces[0]!.target).toBe("#dot"); + expect(traces[0]!.strokes).toHaveLength(2); + }); + + it("treats a 0-duration set() between strokes as a pen-up jump, not a drawn stroke", () => { + const html = wrap(` + const tl = gsap.timeline({ paused: true }); + tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 0 } }, duration: 1 }); + tl.set("#dot", { x: 200, y: 200 }); + tl.to("#dot", { keyframes: { "0%": { x: 200, y: 200 }, "100%": { x: 250, y: 250 } }, duration: 1 }); + window.__timelines = [tl]; + `); + const { traces } = surfaceComposition(html, "index.html", "index.html"); + expect(traces).toHaveLength(1); + // two DRAWN strokes; the set() is the pen-up gap and is excluded + expect(traces[0]!.strokes).toHaveLength(2); + }); + + it("leaves a single-stroke element untraced (normal per-tween output)", () => { + const html = wrap(` + const tl = gsap.timeline({ paused: true }); + tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 200, y: -100 }, "100%": { x: 0, y: 0 } }, duration: 3 }); + window.__timelines = [tl]; + `); + const { traces, tweens } = surfaceComposition(html, "index.html", "index.html"); + expect(traces).toHaveLength(0); + expect(tweens.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts new file mode 100644 index 0000000000..411c449e19 --- /dev/null +++ b/packages/cli/src/commands/keyframes.ts @@ -0,0 +1,487 @@ +import { defineCommand } from "citty"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; + +export const examples: Example[] = [ + ["Surface every keyframe + motion path in the project", "hyperframes keyframes"], + ["Inspect one composition file", "hyperframes keyframes compositions/scene.html"], + ["Machine-readable output for an agent", "hyperframes keyframes --json"], + ["Only one element's tweens", "hyperframes keyframes --selector '#puck-a'"], +]; + +// ── Surfaced shapes ────────────────────────────────────────────────────────── + +interface KeyframePoint { + /** Tween-relative percentage (0–100). */ + pct: number; + /** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */ + time: number; + properties: Record; +} + +interface SurfacedTween { + id: string; + target: string; + method: string; + group?: string; + start: number; + duration: number; + end: number; + /** "keyframes" (array/object form), "flat" (to/from), or "motionPath". */ + shape: "keyframes" | "flat" | "motionPath"; + keyframes: KeyframePoint[]; + /** x/y position points (gsap offsets) when this tween animates position. */ + path: Array<{ x: number; y: number }> | null; +} + +/** One drawn stroke of a multi-stroke trace — a single position tween. */ +interface TraceStroke { + id: string; + start: number; + end: number; + keyframes: KeyframePoint[]; + points: Array<{ x: number; y: number }>; +} + +/** An element's position motion composited into ordered strokes. The gaps + * between strokes are pen-up jumps (a 0-duration `set`, or a discontinuity) + * and are NOT drawn — this is how one element traces shapes with holes or + * detached parts (a `?` dot, an icon counter, multi-letter words). */ +interface SurfacedTrace { + target: string; + strokes: TraceStroke[]; +} + +interface SurfacedComposition { + composition: string; + source: string; + tweens: SurfacedTween[]; + /** Multi-stroke traces: targets with ≥2 drawn position strokes, composited. */ + traces: SurfacedTrace[]; +} + +// ── GSAP extraction ────────────────────────────────────────────────────────── + +function inlineScriptText(html: string): string { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("script")) + .filter((s) => !s.getAttribute("src")) + .map((s) => s.textContent ?? "") + .join("\n"); +} + +function num(v: number | string | undefined): number | null { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isPositionTween(anim: GsapAnimation): boolean { + if (anim.propertyGroup === "position") return true; + const has = (p: Record | undefined) => !!p && ("x" in p || "y" in p); + if (has(anim.properties) || has(anim.fromProperties)) return true; + return (anim.keyframes?.keyframes ?? []).some( + (kf) => "x" in kf.properties || "y" in kf.properties, + ); +} + +// The rest-state value for an animated property (what GSAP animates to/from when +// the other endpoint is the element's natural pose): 1 for scale/opacity, 0 for +// translate/rotation. +function baseProps(props: Record): Record { + const base: Record = {}; + for (const k of Object.keys(props)) { + if (k === "ease") continue; + base[k] = k === "opacity" || k.startsWith("scale") ? 1 : 0; + } + return base; +} + +// Flat tweens carry no explicit keyframes — synthesize a 0%/100% pair against the +// element's rest pose so the surfaced keyframes are uniform. `from()` goes +// fromProperties → base; `to()` goes base → properties. +function flatKeyframes(anim: GsapAnimation): KeyframePoint[] { + if (anim.method === "fromTo") { + return [ + { pct: 0, time: 0, properties: anim.fromProperties ?? {} }, + { pct: 100, time: 0, properties: anim.properties ?? {} }, + ]; + } + // to()/from() vars both live in anim.properties; from() plays them in reverse + // against the element's rest pose. + const vars = anim.properties ?? {}; + const base = baseProps(vars); + return anim.method === "from" + ? [ + { pct: 0, time: 0, properties: vars }, + { pct: 100, time: 0, properties: base }, + ] + : [ + { pct: 0, time: 0, properties: base }, + { pct: 100, time: 0, properties: vars }, + ]; +} + +// Studio-internal markers that aren't user motion: the position-hold `set` GSAP +// runs before a keyframed position tween (`data: "hf-hold"`). +function isHoldMarker(anim: GsapAnimation): boolean { + return anim.properties?.data === "hf-hold" || anim.fromProperties?.data === "hf-hold"; +} + +// Drop internal / non-visual keys so they don't pollute the surfaced keyframes. +function cleanProps(props: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(props)) { + if (k === "data" || k === "ease") continue; + out[k] = v; + } + return out; +} + +function surfaceTween(anim: GsapAnimation): SurfacedTween { + const start = + typeof anim.resolvedStart === "number" ? anim.resolvedStart : (num(anim.position) ?? 0); + const duration = anim.duration ?? 0; + + let shape: SurfacedTween["shape"]; + let rawKfs: Array<{ percentage: number; properties: Record }>; + if (anim.keyframes?.keyframes?.length) { + shape = "keyframes"; + rawKfs = anim.keyframes.keyframes; + } else if (anim.arcPath?.enabled) { + shape = "motionPath"; + rawKfs = []; + } else { + shape = "flat"; + rawKfs = flatKeyframes(anim).map((k) => ({ percentage: k.pct, properties: k.properties })); + } + + const keyframes: KeyframePoint[] = rawKfs.map((kf) => ({ + pct: kf.percentage, + time: Math.round((start + (kf.percentage / 100) * duration) * 1000) / 1000, + properties: cleanProps(kf.properties), + })); + + return { + id: anim.id, + target: anim.targetSelector, + method: anim.method, + group: anim.propertyGroup, + start: Math.round(start * 1000) / 1000, + duration, + end: Math.round((start + duration) * 1000) / 1000, + shape, + keyframes, + path: isPositionTween(anim) ? positionPath(keyframes) : null, + }; +} + +// Carry x/y forward across keyframes that only set one axis, so the path is +// continuous (GSAP holds the last value for an unspecified property). +function positionPath(keyframes: KeyframePoint[]): Array<{ x: number; y: number }> | null { + if (keyframes.length === 0) return null; + let lastX = 0; + let lastY = 0; + return keyframes.map((kf) => { + const x = num(kf.properties.x); + const y = num(kf.properties.y); + if (x !== null) lastX = x; + if (y !== null) lastY = y; + return { x: lastX, y: lastY }; + }); +} + +// ── Composition surfacing ──────────────────────────────────────────────────── + +export function surfaceComposition( + html: string, + label: string, + source: string, +): SurfacedComposition { + const script = inlineScriptText(html); + let animations: GsapAnimation[] = []; + try { + animations = parseGsapScript(script).animations; + } catch { + animations = []; + } + const tweens = animations.filter((a) => !isHoldMarker(a)).map(surfaceTween); + return { composition: label, source, tweens, traces: groupTraces(tweens) }; +} + +// Group an element's DRAWN position strokes (to/from/fromTo/keyframes that carry +// a path) into one ordered trace. A `set` with x/y is a pen-up jump — excluded +// (not drawn). Only targets with ≥2 strokes become a composited trace; a single +// stroke stays on the normal per-tween path so existing output is unchanged. +function groupTraces(tweens: SurfacedTween[]): SurfacedTrace[] { + const byTarget = new Map(); + for (const t of tweens) { + if (t.method === "set") continue; + if (!t.path || t.path.length < 2) continue; + const list = byTarget.get(t.target); + if (list) list.push(t); + else byTarget.set(t.target, [t]); + } + const traces: SurfacedTrace[] = []; + for (const [target, list] of byTarget) { + if (list.length < 2) continue; + const strokes = [...list] + .sort((a, b) => a.start - b.start) + .map((t) => ({ + id: t.id, + start: t.start, + end: t.end, + keyframes: t.keyframes, + points: t.path!, + })); + traces.push({ target, strokes }); + } + return traces; +} + +function collectCompositions(indexPath: string): SurfacedComposition[] { + const html = readFileSync(indexPath, "utf-8"); + const baseDir = dirname(indexPath); + const out: SurfacedComposition[] = [ + surfaceComposition(html, basename(indexPath), basename(indexPath)), + ]; + + const doc = new DOMParser().parseFromString(html, "text/html"); + for (const div of Array.from(doc.querySelectorAll("[data-composition-src]"))) { + const src = div.getAttribute("data-composition-src"); + if (!src) continue; + const subPath = resolve(baseDir, src); + if (!existsSync(subPath)) continue; + const id = div.getAttribute("data-composition-id") ?? src; + out.push(surfaceComposition(readFileSync(subPath, "utf-8"), id, src)); + } + return out; +} + +// ── Render (human) ─────────────────────────────────────────────────────────── + +function fmtProps(props: Record): string { + return Object.entries(props) + .filter(([k]) => k !== "ease") + .map(([k, v]) => `${k}:${v}`) + .join(" "); +} + +function printTween(t: SurfacedTween): void { + const timing = c.dim(`@${t.start}s→${t.end}s (${t.duration}s)`); + const group = t.group ? c.dim(` ${t.group}`) : ""; + console.log(` ${c.accent(t.target)}${group} ${c.dim(t.method)}/${t.shape} ${timing}`); + if (t.shape === "motionPath") { + console.log(c.dim(` motionPath arc (${t.keyframes.length} stops)`)); + } else { + const kfLine = t.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" "); + console.log(` ${c.dim(kfLine)}`); + } + console.log(); +} + +function printTrace(tr: SurfacedTrace): void { + const start = Math.min(...tr.strokes.map((s) => s.start)); + const end = Math.max(...tr.strokes.map((s) => s.end)); + const n = tr.strokes.length; + console.log( + ` ${c.accent(tr.target)}${c.dim(" position")} ${c.dim("trace")} ${c.dim(`${n} strokes`)} ${c.dim(`@${start}s→${end}s`)}`, + ); + tr.strokes.forEach((s, i) => { + const kfLine = s.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" "); + console.log(` ${c.dim(`stroke ${i + 1}:`)} ${c.dim(kfLine)}`); + }); + console.log(); +} + +// ── Onion-skin self-verify shot ────────────────────────────────────────────── + +interface ShotArgs { + shot?: string; + samples?: string; + layout?: string; + from?: string; + to?: string; + fit?: boolean; + angle?: string; +} + +// Every animated element qualifies — the onion samples the live element and shows +// every channel (rotation / scale / opacity / colour / 3D), not just x/y. A +// 0-duration `set` is a pen-up marker, not motion. +function collectAnimatedSelectors(comps: SurfacedComposition[]): Array<{ selector: string }> { + const selectors = new Set(); + for (const cmp of comps) { + for (const tr of cmp.traces) selectors.add(tr.target); + for (const t of cmp.tweens) { + if (t.method !== "set") selectors.add(t.target); + } + } + return [...selectors].map((selector) => ({ selector })); +} + +/** Render the 3D onion-skin screenshot for every animated element. Returns true + * when the command should early-return (a guard failed). */ +async function runOnionShot( + comps: SurfacedComposition[], + projectDir: string | undefined, + args: ShotArgs, +): Promise { + const { captureMotionPathShot } = await import("./keyframesShot.js"); + const requests = collectAnimatedSelectors(comps); + if (!projectDir) { + console.log(c.dim("--shot needs a project directory (not a single .html file).")); + return true; + } + if (requests.length === 0) { + console.log(c.dim("--shot: no animated element to sample for the selection.")); + return true; + } + const saved = await captureMotionPathShot(projectDir, requests, resolve(args.shot!), { + samples: num(args.samples) ?? 9, + layout: args.layout === "strip" ? "strip" : "path", + fit: args.fit ?? true, + from: num(args.from), + to: num(args.to), + angle: args.angle, + }); + console.log(`${c.success("◇")} onion-skin screenshot saved ${c.accent(saved)}`); + console.log( + c.dim( + ` ${requests.length} element${requests.length === 1 ? "" : "s"} · open it to verify the motion matches your target, then read the keyframes below.`, + ), + ); + console.log(); + return false; +} + +// Resolve the command target (a project dir or a single .html) into surfaced +// compositions, applying the optional --selector filter. +function resolveScope(args: { target?: string; selector?: string }): { + comps: SurfacedComposition[]; + projectName: string; + projectDir: string | undefined; +} { + const raw = args.target?.trim(); + let comps: SurfacedComposition[]; + let projectName: string; + let projectDir: string | undefined; + if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { + comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)]; + projectName = basename(raw); + projectDir = dirname(raw); + } else { + const project = resolveProject(raw); + comps = collectCompositions(project.indexPath); + projectName = project.name; + projectDir = project.dir; + } + if (args.selector) { + const sel = args.selector; + const matches = (target: string) => target.split(",").some((s) => s.trim() === sel); + comps = comps + .map((cmp) => ({ + ...cmp, + tweens: cmp.tweens.filter((t) => matches(t.target)), + traces: cmp.traces.filter((tr) => matches(tr.target)), + })) + .filter((cmp) => cmp.tweens.length > 0 || cmp.traces.length > 0); + } + return { comps, projectName, projectDir }; +} + +// Print one composition's traces + tweens (skipping strokes already shown in a trace). +function printComposition(cmp: SurfacedComposition): void { + if (cmp.tweens.length === 0 && cmp.traces.length === 0) return; + console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`)); + const tracedIds = new Set(cmp.traces.flatMap((tr) => tr.strokes.map((s) => s.id))); + const tracedTargets = new Set(cmp.traces.map((tr) => tr.target)); + for (const tr of cmp.traces) printTrace(tr); + for (const t of cmp.tweens) { + if (tracedIds.has(t.id)) continue; // already shown as part of its trace + if (t.method === "set" && tracedTargets.has(t.target)) continue; // internal pen-up jump + printTween(t); + } +} + +// ── Command ────────────────────────────────────────────────────────────────── + +export default defineCommand({ + meta: { + name: "keyframes", + description: "Surface every GSAP tween, keyframe, and motion path for agent-driven editing", + }, + args: { + target: { + type: "positional", + description: "Project dir or composition .html", + required: false, + }, + selector: { type: "string", description: "Only tweens matching this CSS selector" }, + json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false }, + shot: { + type: "string", + description: + "Onion-skin screenshot to PNG: the real element sampled over the timeline (true 3D, every channel) for visual self-verify. Pair with --selector to focus one element.", + }, + samples: { + type: "string", + description: "Onion samples (equal-time steps) for --shot. Default 9.", + }, + layout: { + type: "string", + description: + "--shot layout: 'path' (ghosts at real positions + path, default) or 'strip' (filmstrip by time — for in-place/overlapping motion).", + }, + from: { type: "string", description: "--shot: sample only from this time (seconds)." }, + to: { type: "string", description: "--shot: sample only up to this time (seconds)." }, + angle: { + type: "string", + description: + "--shot orbit camera: a preset (front|iso|top|side|rear-iso) or 'yaw,pitch' degrees — view 3D motion from the angle that reveals it.", + }, + fit: { + type: "boolean", + description: "--shot: zoom the motion to fill the frame (default true; --no-fit to disable).", + default: true, + }, + }, + async run({ args }) { + ensureDOMParser(); + const { comps, projectName, projectDir } = resolveScope(args); + + // --shot: 3D onion-skin self-verify screenshot. Returns true when the command + // should stop (guard failure) so run() stays small. + if (args.shot && (await runOnionShot(comps, projectDir, args))) return; + + if (args.json) { + console.log(JSON.stringify(withMeta({ project: projectName, compositions: comps }), null, 2)); + return; + } + + const total = comps.reduce((n, cmp) => n + cmp.tweens.length, 0); + if (total === 0) { + console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no GSAP tweens found")}`); + return; + } + console.log( + `${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} tween${total === 1 ? "" : "s"}`)}`, + ); + console.log(); + for (const cmp of comps) printComposition(cmp); + console.log( + c.dim( + "Tip: edit the keyframes in source, then `keyframes --shot out.png` to see the rendered motion.", + ), + ); + }, +}); diff --git a/packages/cli/src/commands/keyframesShot.ts b/packages/cli/src/commands/keyframesShot.ts new file mode 100644 index 0000000000..d9f84382d4 --- /dev/null +++ b/packages/cli/src/commands/keyframesShot.ts @@ -0,0 +1,292 @@ +// Onion-skin motion screenshot: seek the LIVE timeline at N equal-time steps and +// project the REAL element at each step, so an agent can SELF-VERIFY motion (the +// rendered result — every channel: position, rotation, scale, opacity, colour), +// not just the authored x/y numbers. Reuses the headless-Chrome + static-server +// pattern from layout.ts. +// +// 3D is captured for free: zero-size marker children at the element's corners are +// projected by the browser, so a tilted/edge-on element renders as a real quad. +// Framing controls (samples / time window / fit / filmstrip) let the agent frame +// exactly what it's editing. All geometry + SVG live in ./keyframesShotLayout.ts +// (pure, tested); this file only drives the browser and SAMPLES. + +import { writeFileSync } from "node:fs"; +import { + buildOnionSvg, + parseAngle, + sampleTimes, + type OnionElement, +} from "./keyframesShotLayout.js"; + +export interface ShotRequest { + /** CSS selector of the moving element to sample (e.g. "#dot"). */ + selector: string; +} + +export interface ShotOptions { + /** Equal-time samples across the (windowed) timeline. Default 9. */ + samples?: number; + /** "path" = ghosts at real positions + path; "strip" = filmstrip by time. */ + layout?: "path" | "strip"; + /** Zoom the motion to fill the frame. Default true. */ + fit?: boolean; + /** Sample only this time window (seconds) — dense inspection of one phase. */ + from?: number | null; + to?: number | null; + /** Orbit camera: a preset (front|iso|top|side) or "yaw,pitch" degrees. */ + angle?: string; +} + +interface PageSample { + t: number; + q: Array<{ x: number; y: number }>; + c: { x: number; y: number }; + color: string; + opacity: number; +} + +// Runs IN THE BROWSER (serialized by page.evaluate). Make the element's ancestor +// chain preserve-3d, strip intermediate perspective, put one perspective on the +// composition root's parent (the lens) and rotate the root — so the element's own +// 3D is viewed from the requested angle on any composition shape (no #stage assumption). +function applyOrbitCamera(selectors: string[], cam: { yaw: number; pitch: number }): void { + const first = document.querySelector(selectors[0] ?? ""); + const root = + (first?.closest("[data-composition-id]") as HTMLElement | null) ?? + (document.querySelector("#stage") as HTMLElement | null) ?? + (document.body.firstElementChild as HTMLElement | null) ?? + document.body; + for (const sel of selectors) { + let n = document.querySelector(sel) as HTMLElement | null; + while (n && n !== root) { + n.style.transformStyle = "preserve-3d"; + n.style.perspective = "none"; + n = n.parentElement; + } + } + root.style.transformStyle = "preserve-3d"; + root.style.perspective = "none"; + root.style.transformOrigin = "50% 50%"; + root.style.transform = `rotateX(${cam.pitch}deg) rotateY(${cam.yaw}deg)`; + const lens = root.parentElement ?? document.body; + lens.style.perspective = "1600px"; + lens.style.perspectiveOrigin = "50% 50%"; +} + +// Launch headless Chrome, load the composition sized to its canvas, wait for the +// timelines + fonts to be ready. Returns the browser (caller closes it), page, size. +async function openCompositionPage( + url: string, + executablePath: string, +): Promise<{ + browser: import("puppeteer-core").Browser; + page: import("puppeteer-core").Page; + size: { width: number; height: number }; +}> { + const puppeteer = await import("puppeteer-core"); + const browser = await puppeteer.default.launch({ + headless: true, + executablePath, + args: [ + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--enable-webgl", + "--use-gl=angle", + "--use-angle=swiftshader", + ], + }); + const page = await browser.newPage(); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); + const size = await page.evaluate(() => { + const root = document.querySelector("[data-composition-id][data-width][data-height]"); + const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0; + const h = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0; + return { + width: Number.isFinite(w) && w > 0 ? Math.min(w, 4096) : 1920, + height: Number.isFinite(h) && h > 0 ? Math.min(h, 4096) : 1080, + }; + }); + await page.setViewport(size); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); + await page + .waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, { + timeout: 10000, + }) + .catch(() => {}); + await page + .evaluate(async () => { + const d = document as unknown as { fonts?: { ready?: Promise } }; + if (d.fonts?.ready) await d.fonts.ready; + }) + .catch(() => {}); + return { browser, page, size }; +} + +// Longest paused timeline duration (seconds) across all registered timelines. +function timelineDuration(page: import("puppeteer-core").Page): Promise { + return page.evaluate(() => { + const tls = Object.values( + ( + window as unknown as { + __timelines?: Record number; totalDuration?: () => number }>; + } + ).__timelines ?? {}, + ); + let d = 0; + for (const tl of tls) { + try { + d = Math.max(d, (tl.totalDuration?.() ?? tl.duration?.() ?? 0) as number); + } catch { + // skip + } + } + return d; + }); +} + +/** Render `projectDir`'s index headless, sample each element's motion as a 3D + * onion-skin, screenshot to `outPath` (PNG). Returns the saved path. */ +export async function captureMotionPathShot( + projectDir: string, + requests: ShotRequest[], + outPath: string, + opts: ShotOptions = {}, +): Promise { + const samples = Math.max(1, Math.min(60, opts.samples ?? 9)); + const layout = opts.layout ?? "path"; + const fit = opts.fit ?? true; + const camera = parseAngle(opts.angle); + + const { ensureBrowser } = await import("../browser/manager.js"); + const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js"); + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + + const html = await bundleToSingleHtml(projectDir); + const server = await serveStaticProjectHtml( + projectDir, + html, + "Failed to bind keyframes shot server", + ); + let browserInstance: import("puppeteer-core").Browser | undefined; + try { + const browser = await ensureBrowser(); + const opened = await openCompositionPage(server.url, browser.executablePath); + browserInstance = opened.browser; + const { page, size } = opened; + + const times = sampleTimes( + await timelineDuration(page), + samples, + opts.from ?? null, + opts.to ?? null, + ); + + // Orbit camera as its own step (keeps the sampler simple), only when angled. + if (camera.yaw !== 0 || camera.pitch !== 0) { + await page.evaluate( + applyOrbitCamera, + requests.map((r) => r.selector), + camera, + ); + } + + // Sample: seek to each time, read every element's projected corners. Marker + // children (zero-size) inherit the element's full transform chain, so their + // screen positions ARE the 3D projection of each corner. + const elements = (await page.evaluate( + (selectors: string[], ts: number[]) => { + const tls = Object.values( + ( + window as unknown as { + __timelines?: Record void; seek?: (t: number) => void }>; + } + ).__timelines ?? {}, + ); + const seekAll = (t: number) => + tls.forEach((tl) => { + try { + tl.pause?.(); + tl.seek?.(t); + } catch { + // best-effort + } + }); + + const rigs = selectors.map((sel) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) return null; + const w = el.offsetWidth; + const h = el.offsetHeight; + const local: Array<[number, number]> = [ + [0, 0], + [w, 0], + [w, h], + [0, h], + [w / 2, h / 2], + ]; + const markers = local.map(([lx, ly]) => { + const m = document.createElement("div"); + m.style.cssText = `position:absolute;left:${lx}px;top:${ly}px;width:0;height:0;pointer-events:none`; + el.appendChild(m); + return m; + }); + return { el, markers }; + }); + const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] })); + for (const t of ts) { + seekAll(t); + rigs.forEach((rig, i) => { + if (!rig) return; + const pts = rig.markers.map((m) => { + const r = m.getBoundingClientRect(); + return { x: r.left, y: r.top }; + }); + const cs = getComputedStyle(rig.el); + out[i]!.samples.push({ + t: Math.round(t * 1000) / 1000, + q: pts.slice(0, 4), + c: pts[4]!, + color: cs.backgroundColor, + opacity: parseFloat(cs.opacity) || 0, + }); + }); + } + rigs.forEach((rig) => { + if (rig) rig.el.style.visibility = "hidden"; + }); + return out.filter((o) => o.samples.length > 0); + }, + requests.map((r) => r.selector), + times, + )) as OnionElement[]; + + const windowStr = + opts.from != null || opts.to != null ? ` · t ${times[0]}–${times[times.length - 1]}s` : ""; + const camLabel = + camera.yaw === 0 && camera.pitch === 0 + ? "front" + : `yaw ${camera.yaw}° pitch ${camera.pitch}°`; + const label = `${camLabel} · ${layout === "strip" ? "filmstrip" : fit ? "zoom-fit" : "1:1"} · ${times.length} frames${windowStr}`; + const svg = buildOnionSvg(elements, { + layout, + fit, + width: size.width, + height: size.height, + label, + }); + + await page.evaluate((markup: string) => { + document.body.insertAdjacentHTML("beforeend", markup); + }, svg); + await new Promise((r) => setTimeout(r, 60)); + + const buf = await page.screenshot({ type: "png" }); + if (!buf) throw new Error("screenshot returned no data"); + writeFileSync(outPath, buf as Uint8Array); + return outPath; + } finally { + await browserInstance?.close().catch(() => {}); + await server.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/keyframesShotLayout.test.ts b/packages/cli/src/commands/keyframesShotLayout.test.ts new file mode 100644 index 0000000000..a127afa7e4 --- /dev/null +++ b/packages/cli/src/commands/keyframesShotLayout.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + buildOnionSvg, + fitTransform, + parseAngle, + sampleTimes, + stripCells, + type OnionElement, +} from "./keyframesShotLayout.js"; + +describe("sampleTimes", () => { + it("spreads N equal-time steps across the full duration", () => { + expect(sampleTimes(4, 5, null, null)).toEqual([0, 1, 2, 3, 4]); + }); + it("samples only the requested window", () => { + expect(sampleTimes(4, 3, 2, 3)).toEqual([2, 2.5, 3]); + }); + it("returns a single point at the window start when n=1", () => { + expect(sampleTimes(4, 1, 1.5, 3)).toEqual([1.5]); + }); + it("clamps the window to [0, dur]", () => { + expect(sampleTimes(4, 2, -5, 99)).toEqual([0, 4]); + }); +}); + +describe("fitTransform", () => { + it("centres on the bbox midpoint", () => { + const { cx, cy } = fitTransform( + [ + { x: 100, y: 200 }, + { x: 300, y: 400 }, + ], + 1000, + 1000, + ); + expect(cx).toBe(200); + expect(cy).toBe(300); + }); + it("zooms a tiny cluster up (k > 1) but clamps the factor", () => { + const { k } = fitTransform( + [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + 1000, + 1000, + ); + expect(k).toBeGreaterThan(1); + expect(k).toBeLessThanOrEqual(7); + }); + it("shrinks an oversized span (k < 1)", () => { + const { k } = fitTransform( + [ + { x: 0, y: 0 }, + { x: 5000, y: 0 }, + ], + 1000, + 1000, + ); + expect(k).toBeLessThan(1); + expect(k).toBeGreaterThanOrEqual(0.3); + }); + it("is safe on empty input", () => { + expect(fitTransform([], 800, 600)).toEqual({ k: 1, cx: 400, cy: 300 }); + }); +}); + +describe("stripCells", () => { + it("uses a single row for few samples", () => { + expect(stripCells(3, 900, 900)).toMatchObject({ cols: 3, rows: 1 }); + }); + it("uses a roughly square grid for many samples", () => { + expect(stripCells(9, 900, 900)).toMatchObject({ cols: 3, rows: 3 }); + expect(stripCells(13, 1080, 1080)).toMatchObject({ cols: 4, rows: 4 }); + }); +}); + +describe("parseAngle", () => { + it("resolves named presets", () => { + expect(parseAngle("iso")).toEqual({ yaw: 30, pitch: -22 }); + expect(parseAngle("top")).toEqual({ yaw: 0, pitch: -68 }); + }); + it("parses yaw,pitch pairs", () => { + expect(parseAngle("45,-30")).toEqual({ yaw: 45, pitch: -30 }); + }); + it("falls back to front on missing or garbage input", () => { + expect(parseAngle()).toEqual({ yaw: 0, pitch: 0 }); + expect(parseAngle("nonsense")).toEqual({ yaw: 0, pitch: 0 }); + }); +}); + +const sample = (t: number) => ({ + t, + q: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ], + c: { x: 5, y: 5 }, + color: "rgb(34, 211, 238)", + opacity: 1, +}); +const oneElement: OnionElement[] = [{ selector: "#hero", samples: [sample(0), sample(2)] }]; + +describe("buildOnionSvg", () => { + it("path layout: one ghost per sample, a connecting path, and centre dots", () => { + const svg = buildOnionSvg(oneElement, { layout: "path", fit: true, width: 1000, height: 1000 }); + expect(svg.startsWith(" { + const svg = buildOnionSvg(oneElement, { + layout: "strip", + fit: true, + width: 1000, + height: 1000, + }); + expect((svg.match(/ { + const svg = buildOnionSvg(oneElement, { + layout: "path", + fit: true, + width: 800, + height: 800, + label: "front · zoom-fit", + }); + expect(svg).toContain("front"); + }); + it("is safe on empty input", () => { + const svg = buildOnionSvg([], { layout: "path", fit: true, width: 800, height: 800 }); + expect(svg.startsWith(" = { + front: [0, 0], + iso: [30, -22], + top: [0, -68], + side: [78, 0], + "rear-iso": [205, -22], +}; + +/** Parse an angle preset name or "yaw,pitch" degrees into a Camera. */ +export function parseAngle(a?: string): Camera { + if (!a) return { yaw: 0, pitch: 0 }; + const preset = ANGLE_PRESETS[a]; + if (preset) return { yaw: preset[0], pitch: preset[1] }; + const [y, p] = a.split(",").map((n) => Number.parseFloat(n)); + return { yaw: Number.isFinite(y) ? y! : 0, pitch: Number.isFinite(p) ? p! : 0 }; +} + +/** N equal-time sample points across [from?, to?] within [0, dur]. */ +export function sampleTimes( + dur: number, + n: number, + from: number | null, + to: number | null, +): number[] { + const t0 = from != null ? Math.max(0, Math.min(from, dur)) : 0; + const t1 = to != null ? Math.max(0, Math.min(to, dur)) : dur; + const count = Math.max(1, Math.floor(n)); + if (count === 1) return [t0]; + return Array.from({ length: count }, (_, i) => { + const t = t0 + (i / (count - 1)) * (t1 - t0); + return Math.round(t * 1000) / 1000; + }); +} + +/** Scale+centre transform that fits `pts` into a W×H frame (with padding). */ +export function fitTransform( + pts: Pt[], + width: number, + height: number, +): { k: number; cx: number; cy: number } { + if (pts.length === 0) return { k: 1, cx: width / 2, cy: height / 2 }; + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const span = Math.max(maxX - minX, maxY - minY, 1); + const k = Math.max(0.3, Math.min(7, (Math.min(width, height) * 0.8) / span)); + return { k, cx, cy }; +} + +/** Grid geometry for the filmstrip layout. */ +export function stripCells(n: number, width: number, height: number) { + const cols = n <= 5 ? Math.max(1, n) : Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + return { cols, rows, cellW: width / cols, cellH: height / rows }; +} + +const timeColor = (f: number) => `hsl(${190 + f * 150} 90% 65%)`; + +const attrs = (o: Record) => + Object.entries(o) + .map(([k, v]) => `${k}="${v}"`) + .join(" "); + +const polygon = (corners: Pt[], fill: string, fillOpacity: number, stroke: string) => + ` `${round(p.x)},${round(p.y)}`).join(" "), + fill, + "fill-opacity": fillOpacity.toFixed(2), + stroke, + "stroke-width": 2.5, + "stroke-linejoin": "round", + })}/>`; + +const line = (a: Pt, b: Pt, stroke: string, w: number, o: number) => + ``; + +const circle = (p: Pt, r: number, fill: string) => + ``; + +const text = (p: Pt, s: string, fill: string, size = 15) => + `${escapeXml(s)}`; + +const round = (n: number) => Math.round(n * 100) / 100; +const escapeXml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + +const ghost = (corners: Pt[], center: Pt, color: string, opacity: number, f: number): string => { + const tickEnd = { + x: (corners[0]!.x + corners[1]!.x) / 2, + y: (corners[0]!.y + corners[1]!.y) / 2, + }; + return ( + polygon(corners, color, Math.max(0.08, opacity * 0.42), timeColor(f)) + + line(center, tickEnd, timeColor(f), 3, 0.9) + ); +}; + +/** Build the full onion-skin SVG overlay markup from sampled elements. */ +export function buildOnionSvg(elements: OnionElement[], opt: ShotLayoutOptions): string { + const { width: W, height: H } = opt; + let body = ""; + + if (opt.layout === "strip") { + body = stripBody(elements[0]?.samples ?? [], W, H); + } else { + body = pathBody(elements, opt.fit, W, H); + } + + if (opt.label) body += text({ x: 28, y: 40 }, opt.label, timeColor(0), 18); + + return `${body}`; +} + +function pathBody(elements: OnionElement[], fit: boolean, W: number, H: number): string { + const all = elements.flatMap((e) => e.samples.flatMap((s) => [...s.q, s.c])); + const { k, cx, cy } = fit ? fitTransform(all, W, H) : { k: 1, cx: W / 2, cy: H / 2 }; + const M = (p: Pt): Pt => ({ x: (p.x - cx) * k + W / 2, y: (p.y - cy) * k + H / 2 }); + let out = ""; + for (const el of elements) { + const last = el.samples.length - 1; + const fOf = (i: number) => (last <= 0 ? 0 : i / last); + el.samples.forEach((s, i) => (out += ghost(s.q.map(M), M(s.c), s.color, s.opacity, fOf(i)))); + for (let i = 0; i < last; i++) + out += line(M(el.samples[i]!.c), M(el.samples[i + 1]!.c), timeColor(fOf(i)), 3.5, 0.85); + el.samples.forEach((s, i) => { + const c = M(s.c); + out += circle(c, 4, timeColor(fOf(i))); + out += text({ x: c.x + 10, y: c.y + (i % 2 === 0 ? -10 : 18) }, `${s.t}s`, timeColor(fOf(i))); + }); + } + return out; +} + +function stripBody(samples: OnionSample[], W: number, H: number): string { + if (samples.length === 0) return ""; + const { cols, cellW, cellH } = stripCells(samples.length, W, H); + let maxExt = 1; + for (const s of samples) + for (const p of s.q) maxExt = Math.max(maxExt, Math.hypot(p.x - s.c.x, p.y - s.c.y)); + const cellScale = (Math.min(cellW, cellH) * 0.62) / maxExt; + const last = samples.length - 1; + let out = ""; + samples.forEach((s, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const cc = { x: cellW * (col + 0.5), y: cellH * (row + 0.5) }; + const f = last <= 0 ? 0 : i / last; + out += ``; + const corners = s.q.map((p) => ({ + x: cc.x + (p.x - s.c.x) * cellScale, + y: cc.y + (p.y - s.c.y) * cellScale, + })); + out += ghost(corners, cc, s.color, s.opacity, f); + out += text({ x: col * cellW + 12, y: row * cellH + 24 }, `${s.t}s`, timeColor(f), 16); + }); + return out; +} diff --git a/skills/hyperframes-keyframes/SKILL.md b/skills/hyperframes-keyframes/SKILL.md new file mode 100644 index 0000000000..8e9ad412a1 --- /dev/null +++ b/skills/hyperframes-keyframes/SKILL.md @@ -0,0 +1,110 @@ +--- +name: hyperframes-keyframes +description: "See and edit GSAP motion as data in a HyperFrames composition. Run `npx hyperframes keyframes` to surface every tween's keyframes, then `--shot` to render a true-3D onion-skin of the real element, so you reason about an element's MOTION over time — add/move/remove keyframes, refine a path, trace a shape (logo / glyph / icon), tune a 3D flip/tumble, debug 'why does it move there', or read an animation before editing. Supports multi-stroke traces (pen-up gaps) for shapes with holes or detached parts. Use whenever the task is about where/when/how something moves; for authoring new scenes from scratch see hyperframes-animation, for the dev-loop CLI see hyperframes-cli." +--- + +# HyperFrames Keyframes + +Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — the numbers don't show the _shape_, the timing, or what rotation/scale/3D actually look like. `npx hyperframes keyframes` surfaces every GSAP tween and its keyframes (with absolute times) as editable data; then `--shot` renders a **true-3D onion-skin of the real element** so you verify the motion by eye — all before you render. + +This is **read-then-edit-source**, not a mutation command — it never changes files. Pair it with `inspect` (layout over the timeline) and `render` to ship. For the composition contract (the single paused timeline, `data-duration`, determinism) see `hyperframes-core`; to author motion from scratch see `hyperframes-animation`. + +## The loop + +1. **Surface** — `npx hyperframes keyframes [dir|file]` (defaults to `./index.html` + sub-compositions). +2. **Read** the keyframe list against your intent (add `--json` for exact data). +3. **Edit** the `keyframes` / property values in the composition ``; + + it("annotates a child tween with its animated ANCESTOR's motion", () => { + const html = nested(` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { keyframes: { "0%": { x: -300, y: 0 }, "100%": { x: 300, y: 0 } }, duration: 4 }, 0); + tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0); + window.__timelines = [tl]; + `); + const { tweens } = surfaceComposition(html, "index.html", "index.html"); + const core = tweens.find((t) => t.target === "#core"); + expect(core?.composedWith?.map((a) => a.selector)).toContain("#hero"); + // and the ancestor's path EXTENT is summarised (range, not endpoints — so a + // closed loop still reveals its travel) + expect(core?.composedWith?.[0]!.summary).toMatch(/x -300\.\.300/); + }); + + it("does not annotate when the parent isn't animated", () => { + const html = nested(` + const tl = gsap.timeline({ paused: true }); + tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0); + window.__timelines = [tl]; + `); + const { tweens } = surfaceComposition(html, "index.html", "index.html"); + expect(tweens.find((t) => t.target === "#core")?.composedWith).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts index 411c449e19..e0ec3c4de4 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/keyframes.ts @@ -38,6 +38,10 @@ interface SurfacedTween { keyframes: KeyframePoint[]; /** x/y position points (gsap offsets) when this tween animates position. */ path: Array<{ x: number; y: number }> | null; + /** Animated ANCESTOR elements (nested composition): this element's rendered + * motion is composed with theirs. Surfaced so a reader of the text/JSON + * doesn't miss a parent's path/trajectory that lives on another element. */ + composedWith?: Array<{ selector: string; summary: string }>; } /** One drawn stroke of a multi-stroke trace — a single position tween. */ @@ -215,9 +219,83 @@ export function surfaceComposition( animations = []; } const tweens = animations.filter((a) => !isHoldMarker(a)).map(surfaceTween); + attachComposedAncestors(tweens, html); return { composition: label, source, tweens, traces: groupTraces(tweens) }; } +// A nested element's rendered motion is the COMPOSITION of its own tween and any +// animated ancestor's. The per-element surface would otherwise hide the parent's +// trajectory (e.g. a child carries a flap while the parent carries the path), so +// annotate each tween with the animated ancestor elements above it in the DOM. +function attachComposedAncestors(tweens: SurfacedTween[], html: string): void { + const animated = [...new Set(tweens.filter((t) => t.method !== "set").map((t) => t.target))]; + if (animated.length < 2) return; // need ≥2 distinct animated elements to compose + const doc = new DOMParser().parseFromString(html, "text/html"); + for (const t of tweens) { + const ancestors = animatedAncestors(doc, t.target, animated); + if (ancestors.length) { + t.composedWith = ancestors.map((sel) => ({ + selector: sel, + summary: summarizeMotion(tweens, sel), + })); + } + } +} + +const safeMatches = (el: Element, sel: string): boolean => { + try { + return el.matches(sel); + } catch { + return false; + } +}; + +// Animated-target selectors of `target`'s DOM ancestors (in order, parent-first). +function animatedAncestors(doc: Document, target: string, animated: string[]): string[] { + let el: Element | null = null; + try { + el = doc.querySelector(target); + } catch { + return []; + } + const out: string[] = []; + for (let n = el?.parentElement ?? null; n; n = n.parentElement) { + for (const sel of animated) { + if (sel !== target && !out.includes(sel) && safeMatches(n, sel)) out.push(sel); + } + } + return out; +} + +// Compact extent summary of an element's motion: each animated property's min..max +// across all its keyframes. Ranges (not endpoints) so a CLOSED loop — a figure-8 +// or orbit returning to its start — still reveals its travel instead of reading +// static (0→0). +function summarizeMotion(tweens: SurfacedTween[], sel: string): string { + const ranges = new Map(); + const kfs = tweens + .filter((t) => t.target === sel && t.method !== "set") + .flatMap((t) => t.keyframes); + for (const kf of kfs) { + for (const [k, v] of Object.entries(kf.properties)) { + const n = num(v); + if (n !== null) bumpRange(ranges, k, n); + } + } + const varying = [...ranges.entries()] + .filter(([, r]) => r.max - r.min > 0.5) + .map(([k, r]) => `${k} ${Math.round(r.min)}..${Math.round(r.max)}`); + return varying.length ? varying.join(", ") : "(static)"; +} + +function bumpRange(ranges: Map, k: string, n: number): void { + const r = ranges.get(k); + if (r) { + r.min = Math.min(r.min, n); + r.max = Math.max(r.max, n); + } else ranges.set(k, { min: n, max: n }); +} + // Group an element's DRAWN position strokes (to/from/fromTo/keyframes that carry // a path) into one ordered trace. A `set` with x/y is a pen-up jump — excluded // (not drawn). Only targets with ≥2 strokes become a composited trace; a single @@ -286,6 +364,11 @@ function printTween(t: SurfacedTween): void { const kfLine = t.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" "); console.log(` ${c.dim(kfLine)}`); } + if (t.composedWith?.length) { + for (const a of t.composedWith) { + console.log(c.dim(` ↑ composed with ${c.accent(a.selector)}${c.dim(": " + a.summary)}`)); + } + } console.log(); } From d46e7ba843e6b96dfeb3d9f2da347ab0110f7b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 10:27:28 -0400 Subject: [PATCH 06/23] docs(skill): layered-motion patterns (fast channel own tween, ground-aligned squash vs spin, heading=tangent, per-channel check) + composed-surface note --- skills/hyperframes-keyframes/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/skills/hyperframes-keyframes/SKILL.md b/skills/hyperframes-keyframes/SKILL.md index 104490469b..418e36f31c 100644 --- a/skills/hyperframes-keyframes/SKILL.md +++ b/skills/hyperframes-keyframes/SKILL.md @@ -94,6 +94,18 @@ tl.to( The child's rendered position is the **composition** of both, so `--shot --selector '#core'` (the leaf) shows the combined motion — the corner markers inherit the full ancestor transform, and the orbit camera handles the chain. Use nesting whenever cramming everything into one tween would force you to trade one channel for another. For motion that genuinely derives from a **single parameter** (a parametric path), one keyframes block is correct — reach for nesting only when channels would otherwise collide. +The text surface shows it too: a nested element's block prints `↑ composed with #group: x −360..360, y −100..100, …` (the ancestor's motion **extent**, so a closed loop isn't hidden as `0→0`). Don't conclude "no path" from a child's own tween — read the `↑ composed with` line (or the `--shot`). + +### Patterns that separate a 9 from a 5 + +These are the layered-motion mistakes that look fine in the numbers and fail on screen: + +- **A fast channel needs its own dense tween.** A wing-flap, shimmer, or rotor wobble at "many cycles" can't share the path's coarse keyframe grid (you'll get ~12 lazy cycles, not rapid). Put the high-frequency channel on its **own** tween/child with enough stops (or a short `repeat`), decoupled from the path. +- **Squash stays flat to the ground — even while spinning.** If an element both rolls/spins **and** squashes on impact, they fight on one element: the squash rotates with the spin and skews off-axis. Split them — **spin on an inner child, squash (scaleX/scaleY) on an outer wrapper** that doesn't rotate — so the squash stays aligned to the floor. +- **"Points along travel" = the path tangent.** For heading/banking that follows the path, derive the rotation from the **velocity direction** (`atan2(Δy, Δx)` between keyframes), not an eyeballed linear ramp — and remember `#hero`'s notch points **up** at `0°`, so add the offset that maps `0°` to your travel convention. +- **Lock coupled phases.** If a spin should track the orbit (or a flip the bounce), derive both from the **same parameter** so they don't drift; if they're meant to be independent, give them clearly different rates. +- **Verify every named channel.** Before stopping, check each channel in the brief against the render one by one — a layered motion fails by dropping _one_ channel (the bob, the bank), not by getting the headline path wrong. + ## Editing keyframes Percentages are **tween-relative**; edits go in the composition ``; -describe("keyframes multi-stroke traces", () => { +describe("motion multi-stroke traces", () => { it("composites ≥2 position strokes on one element into a single trace", () => { const html = wrap(` const tl = gsap.timeline({ paused: true }); @@ -47,7 +47,7 @@ describe("keyframes multi-stroke traces", () => { }); }); -describe("keyframes composed-ancestor surfacing (nested elements)", () => { +describe("motion composed-ancestor surfacing (nested elements)", () => { const nested = (script: string) => `
`; diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/motion.ts similarity index 97% rename from packages/cli/src/commands/keyframes.ts rename to packages/cli/src/commands/motion.ts index e0ec3c4de4..dd459c07c9 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/motion.ts @@ -9,10 +9,10 @@ import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; export const examples: Example[] = [ - ["Surface every keyframe + motion path in the project", "hyperframes keyframes"], - ["Inspect one composition file", "hyperframes keyframes compositions/scene.html"], - ["Machine-readable output for an agent", "hyperframes keyframes --json"], - ["Only one element's tweens", "hyperframes keyframes --selector '#puck-a'"], + ["Surface every keyframe + motion path in the project", "hyperframes motion"], + ["Inspect one composition file", "hyperframes motion compositions/scene.html"], + ["Machine-readable output for an agent", "hyperframes motion --json"], + ["Only one element's tweens", "hyperframes motion --selector '#puck-a'"], ]; // ── Surfaced shapes ────────────────────────────────────────────────────────── @@ -419,7 +419,7 @@ async function runOnionShot( projectDir: string | undefined, args: ShotArgs, ): Promise { - const { captureMotionPathShot } = await import("./keyframesShot.js"); + const { captureMotionPathShot } = await import("./motionShot.js"); const requests = collectAnimatedSelectors(comps); if (!projectDir) { console.log(c.dim("--shot needs a project directory (not a single .html file).")); @@ -500,8 +500,9 @@ function printComposition(cmp: SurfacedComposition): void { export default defineCommand({ meta: { - name: "keyframes", - description: "Surface every GSAP tween, keyframe, and motion path for agent-driven editing", + name: "motion", + description: + "See, debug, and refine motion — surface every GSAP tween, keyframe, and motion path, then --shot the onion-skin", }, args: { target: { @@ -563,7 +564,7 @@ export default defineCommand({ for (const cmp of comps) printComposition(cmp); console.log( c.dim( - "Tip: edit the keyframes in source, then `keyframes --shot out.png` to see the rendered motion.", + "Tip: edit the keyframes in source, then `motion --shot out.png` to see the rendered motion.", ), ); }, diff --git a/packages/cli/src/commands/keyframesShot.ts b/packages/cli/src/commands/motionShot.ts similarity index 97% rename from packages/cli/src/commands/keyframesShot.ts rename to packages/cli/src/commands/motionShot.ts index d9f84382d4..3b177b09b8 100644 --- a/packages/cli/src/commands/keyframesShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -7,16 +7,11 @@ // 3D is captured for free: zero-size marker children at the element's corners are // projected by the browser, so a tilted/edge-on element renders as a real quad. // Framing controls (samples / time window / fit / filmstrip) let the agent frame -// exactly what it's editing. All geometry + SVG live in ./keyframesShotLayout.ts +// exactly what it's editing. All geometry + SVG live in ./motionShotLayout.ts // (pure, tested); this file only drives the browser and SAMPLES. import { writeFileSync } from "node:fs"; -import { - buildOnionSvg, - parseAngle, - sampleTimes, - type OnionElement, -} from "./keyframesShotLayout.js"; +import { buildOnionSvg, parseAngle, sampleTimes, type OnionElement } from "./motionShotLayout.js"; export interface ShotRequest { /** CSS selector of the moving element to sample (e.g. "#dot"). */ @@ -166,7 +161,7 @@ export async function captureMotionPathShot( const server = await serveStaticProjectHtml( projectDir, html, - "Failed to bind keyframes shot server", + "Failed to bind motion shot server", ); let browserInstance: import("puppeteer-core").Browser | undefined; try { diff --git a/packages/cli/src/commands/keyframesShotLayout.test.ts b/packages/cli/src/commands/motionShotLayout.test.ts similarity index 99% rename from packages/cli/src/commands/keyframesShotLayout.test.ts rename to packages/cli/src/commands/motionShotLayout.test.ts index a127afa7e4..459b0b024b 100644 --- a/packages/cli/src/commands/keyframesShotLayout.test.ts +++ b/packages/cli/src/commands/motionShotLayout.test.ts @@ -6,7 +6,7 @@ import { sampleTimes, stripCells, type OnionElement, -} from "./keyframesShotLayout.js"; +} from "./motionShotLayout.js"; describe("sampleTimes", () => { it("spreads N equal-time steps across the full duration", () => { diff --git a/packages/cli/src/commands/keyframesShotLayout.ts b/packages/cli/src/commands/motionShotLayout.ts similarity index 98% rename from packages/cli/src/commands/keyframesShotLayout.ts rename to packages/cli/src/commands/motionShotLayout.ts index ccf6206997..a707458109 100644 --- a/packages/cli/src/commands/keyframesShotLayout.ts +++ b/packages/cli/src/commands/motionShotLayout.ts @@ -1,6 +1,6 @@ // Pure, Node-side geometry + SVG generation for the onion-skin motion shot. // -// The headless step (keyframesShot.ts) only SAMPLES — it seeks the live timeline +// The headless step (motionShot.ts) only SAMPLES — it seeks the live timeline // and reads each element's projected corners. Everything else (which times to // sample, how to fit/lay them out, and the SVG markup) lives here as pure // functions so it can be unit-tested without a browser. diff --git a/skills/hyperframes-keyframes/SKILL.md b/skills/hyperframes-keyframes/SKILL.md deleted file mode 100644 index 8aae87fd23..0000000000 --- a/skills/hyperframes-keyframes/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: hyperframes-keyframes -description: "See and edit GSAP motion as data in a HyperFrames composition. Run `npx hyperframes keyframes` to surface every tween's keyframes, then `--shot` to render a true-3D onion-skin of the real element, so you reason about an element's MOTION over time — add/move/remove keyframes, refine a path, trace a shape (logo / glyph / icon), tune a 3D flip/tumble, debug 'why does it move there', or read an animation before editing. Supports multi-stroke traces (pen-up gaps) for shapes with holes or detached parts. Use whenever the task is about where/when/how something moves; for authoring new scenes from scratch see hyperframes-animation, for the dev-loop CLI see hyperframes-cli." ---- - -# HyperFrames Keyframes - -Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — the numbers don't show the _shape_, the timing, or what rotation/scale/3D actually look like. `npx hyperframes keyframes` surfaces every GSAP tween and its keyframes (with absolute times) as editable data; then `--shot` renders a **true-3D onion-skin of the real element** so you verify the motion by eye — all before you render. - -This is **read-then-edit-source**, not a mutation command — it never changes files. Pair it with `inspect` (layout over the timeline) and `render` to ship. For the composition contract (the single paused timeline, `data-duration`, determinism) see `hyperframes-core`; to author motion from scratch see `hyperframes-animation`. - -## The loop - -1. **Surface** — `npx hyperframes keyframes [dir|file]` (defaults to `./index.html` + sub-compositions). -2. **Read** the keyframe list against your intent (add `--json` for exact data). -3. **Edit** the `keyframes` / property values in the composition ` +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined(); + expect( + result.findings.find((f) => f.code === "timeline_registry_missing_init"), + ).toBeUndefined(); + // Key "comp-1" matches the DOM id, so no mismatch. + expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined(); + }); + + it("reports mismatched object-literal timeline registration keys", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timeline_id_mismatch"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('Timeline registered as "main"'); + }); }); it("warns when a timeline-visible element has no stable id for Studio editing", async () => { diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index 5a057ad4bf..271f22701f 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -9,6 +9,7 @@ import { getInlineScriptSyntaxError, TIMELINE_REGISTRY_INIT_PATTERN, TIMELINE_REGISTRY_ASSIGN_PATTERN, + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, INVALID_SCRIPT_CLOSE_PATTERN, } from "../utils"; @@ -93,7 +94,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ const findings: HyperframeLintFinding[] = []; if ( !TIMELINE_REGISTRY_INIT_PATTERN.test(source) && - !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) + !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && + !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source) ) { findings.push({ code: "missing_timeline_registry", diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 4e2d67b743..4de81d974c 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -28,6 +28,7 @@ import { stripJsComments, WINDOW_TIMELINE_ASSIGN_PATTERN, TIMELINE_REGISTRY_ASSIGN_PATTERN, + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, } from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -750,7 +751,9 @@ export const gsapRules: LintRule[] = [ for (const script of scripts) { const content = script.content; if (!/gsap\.timeline/.test(content)) continue; - const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content); + const hasRegistration = + WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content); if (hasRegistration || canInheritFromHost) continue; findings.push({ code: "gsap_timeline_not_registered", diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts index 2030a0658b..baefe699c6 100644 --- a/packages/core/src/lint/utils.ts +++ b/packages/core/src/lint/utils.ts @@ -21,6 +21,11 @@ export const SCRIPT_BLOCK_PATTERN = /]*)>([\s\S]*?)<\/script>/gi; const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g; export const TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i; +// Object-literal registration that assigns at least one `key: value` entry inline, +// e.g. `window.__timelines = { main: tl }` or `window.__timelines = { "comp-1": tl }`. +// Distinct from the empty-init form (`= {}`) — requires a key followed by `:`. +export const TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = + /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i; export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i; export const WINDOW_TIMELINE_ASSIGN_PATTERN = @@ -30,6 +35,14 @@ export const INVALID_SCRIPT_CLOSE_PATTERN = /]*>[\s\S]*?<\s*\/\s*scrip const TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g; +// The `window.__timelines = { ... }` object-literal body (group 1), captured so its +// `key: value` entries can be scanned for registered keys. +const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i; +// A single object-literal entry whose value is an identifier (real timeline registration), +// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare). +const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = + /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g; + export function extractOpenTags(source: string): OpenTag[] { const tags: OpenTag[] = []; let match: RegExpExecArray | null; @@ -156,6 +169,17 @@ export function extractTimelineRegistryKeys(source: string): string[] { const key = match[1] ?? match[2]; if (key) keys.add(key); } + const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1]; + if (objectBody) { + const entryPattern = new RegExp( + TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source, + TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags, + ); + while ((match = entryPattern.exec(objectBody)) !== null) { + const key = match[1] ?? match[2]; + if (key) keys.add(key); + } + } return [...keys]; } From 4c313f46feb1f4bd5d385eb9b84cb3015154aad6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 01:53:01 -0400 Subject: [PATCH 16/23] fix(core): warn loudly when timelines registered but none bind The render path seeks window.__timelines[root data-composition-id]; a missing/mismatched id silently rendered frozen at t=0 while --shot (which seeks __timelines directly) looked fine. Surface a diagnostic + console.warn at render-ready instead of failing silently. --- packages/core/src/runtime/init.test.ts | 21 +++++++++++++++++++ packages/core/src/runtime/init.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2549c21bb9..e5fc55b5d4 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -146,6 +146,27 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); + it("warns loudly when GSAP timelines are registered but none bind (frozen-render contract trap)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Root is MISSING data-composition-id, so the render path cannot resolve a + // timeline even though one is registered → would silently render frozen at t=0. + const root = document.createElement("div"); + root.className = "clip"; + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { main: createMockTimeline(6) }; + + initSandboxRuntimeModular(); + + const warned = warnSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(warned).toContain("[HyperFrames]"); + expect(warned).toContain("none bound"); + }); + it("uses the shorter authored host window when the child timeline is longer", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bdc0e2c3a2..70fe7e26a8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1964,6 +1964,35 @@ export function initSandboxRuntimeModular(): void { const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (boundDuration > 0) { clock.setDuration(boundDuration); + } else { + // No GSAP timeline bound. Legitimate for CSS/WAAPI/Lottie-only comps + // (adapters drive time). But if GSAP timelines ARE registered yet none + // bound, the contract is broken: the render resolves the root's + // data-composition-id and seeks window.__timelines[], so a + // missing/mismatched id (or the loose `window.__timelines = { main: tl }` + // form) leaves the render FROZEN at t=0 even though `--shot` — which seeks + // __timelines directly — looks fine. Surface it loudly, don't fail silently. + const registry = (window as Record).__timelines as + | Record + | undefined; + const registeredKeys = registry ? Object.keys(registry) : []; + if (registeredKeys.length > 0) { + const rootEl = + document.querySelector("[data-composition-id]") ?? document.querySelector(".clip"); + const rootId = rootEl?.getAttribute("data-composition-id") ?? null; + postRuntimeDiagnosticOnce( + "root_timeline_unbound_registry_present", + { rootCompositionId: rootId, registeredTimelineKeys: registeredKeys }, + "root_timeline_unbound_registry_present", + ); + console.warn( + `[HyperFrames] ${registeredKeys.length} GSAP timeline(s) are registered ` + + `(${registeredKeys.map((k) => JSON.stringify(k)).join(", ")}) but none bound for render. ` + + `The render seeks window.__timelines[root data-composition-id]; the root's data-composition-id is ` + + `${rootId === null ? "MISSING" : JSON.stringify(rootId)}. Set data-composition-id on the .clip root ` + + `and register the timeline under that exact key, or the render stays frozen at t=0 (even though --shot looks correct).`, + ); + } } runAdapters("discover", state.currentTime); // __renderReady = timeline binding attempted, safe for deterministic seeking. From da6f4794191b3fe2c81334a6c87f8fdf00d05995 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 01:53:01 -0400 Subject: [PATCH 17/23] fix(cli): motion --shot --selector falls back to animated descendants A selector matching a static .clip wrapper no longer errors with 'no animated element to sample'; it samples the root's animated descendants. Unblocks the documented '--selector .clip --angle iso' 3D verify command. --- packages/cli/src/commands/motion.ts | 20 +++-- packages/cli/src/commands/motionShot.ts | 90 ++++++++++++++++++- .../cli/src/commands/motionShotLayout.test.ts | 32 +++++++ packages/cli/src/commands/motionShotLayout.ts | 24 +++++ 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/motion.ts b/packages/cli/src/commands/motion.ts index dd459c07c9..694adbf4af 100644 --- a/packages/cli/src/commands/motion.ts +++ b/packages/cli/src/commands/motion.ts @@ -416,11 +416,15 @@ function collectAnimatedSelectors(comps: SurfacedComposition[]): Array<{ selecto * when the command should early-return (a guard failed). */ async function runOnionShot( comps: SurfacedComposition[], + allComps: SurfacedComposition[], projectDir: string | undefined, - args: ShotArgs, + args: ShotArgs & { selector?: string }, ): Promise { const { captureMotionPathShot } = await import("./motionShot.js"); - const requests = collectAnimatedSelectors(comps); + // With --selector, sample from the FULL animated set and let the browser scope + // to the selector (or its animated descendants when the selector is a static + // wrapper like `.clip`). Without it, only the (already-filtered) comps qualify. + const requests = collectAnimatedSelectors(args.selector ? allComps : comps); if (!projectDir) { console.log(c.dim("--shot needs a project directory (not a single .html file).")); return true; @@ -436,6 +440,7 @@ async function runOnionShot( from: num(args.from), to: num(args.to), angle: args.angle, + scopeSelector: args.selector ?? null, }); console.log(`${c.success("◇")} onion-skin screenshot saved ${c.accent(saved)}`); console.log( @@ -451,6 +456,7 @@ async function runOnionShot( // compositions, applying the optional --selector filter. function resolveScope(args: { target?: string; selector?: string }): { comps: SurfacedComposition[]; + allComps: SurfacedComposition[]; projectName: string; projectDir: string | undefined; } { @@ -468,6 +474,10 @@ function resolveScope(args: { target?: string; selector?: string }): { projectName = project.name; projectDir = project.dir; } + // allComps keeps the unfiltered set so --shot --selector can resolve a STATIC + // wrapper (e.g. `.clip`) to its animated descendants in the live DOM, even + // though the literal selector filter (for print/json) drops it to empty. + const allComps = comps; if (args.selector) { const sel = args.selector; const matches = (target: string) => target.split(",").some((s) => s.trim() === sel); @@ -479,7 +489,7 @@ function resolveScope(args: { target?: string; selector?: string }): { })) .filter((cmp) => cmp.tweens.length > 0 || cmp.traces.length > 0); } - return { comps, projectName, projectDir }; + return { comps, allComps, projectName, projectDir }; } // Print one composition's traces + tweens (skipping strokes already shown in a trace). @@ -541,11 +551,11 @@ export default defineCommand({ }, async run({ args }) { ensureDOMParser(); - const { comps, projectName, projectDir } = resolveScope(args); + const { comps, allComps, projectName, projectDir } = resolveScope(args); // --shot: 3D onion-skin self-verify screenshot. Returns true when the command // should stop (guard failure) so run() stays small. - if (args.shot && (await runOnionShot(comps, projectDir, args))) return; + if (args.shot && (await runOnionShot(comps, allComps, projectDir, args))) return; if (args.json) { console.log(JSON.stringify(withMeta({ project: projectName, compositions: comps }), null, 2)); diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts index 3b177b09b8..8afea76627 100644 --- a/packages/cli/src/commands/motionShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -11,13 +11,29 @@ // (pure, tested); this file only drives the browser and SAMPLES. import { writeFileSync } from "node:fs"; -import { buildOnionSvg, parseAngle, sampleTimes, type OnionElement } from "./motionShotLayout.js"; +import { + buildOnionSvg, + parseAngle, + resolveShotSelectors, + sampleTimes, + type OnionElement, +} from "./motionShotLayout.js"; export interface ShotRequest { /** CSS selector of the moving element to sample (e.g. "#dot"). */ selector: string; } +/** Returned by the in-browser selector resolver: which animated selectors a + * `--selector SCOPE` actually resolves to (scope itself, or its descendants), + * plus diagnostic context when nothing under the scope animates. */ +interface ScopeResolution { + /** Animated selectors to sample (subset of `requests`). */ + selectors: string[]; + /** True when the scope selector matched a real element in the DOM. */ + scopeExists: boolean; +} + export interface ShotOptions { /** Equal-time samples across the (windowed) timeline. Default 9. */ samples?: number; @@ -30,6 +46,10 @@ export interface ShotOptions { to?: number | null; /** Orbit camera: a preset (front|iso|top|side) or "yaw,pitch" degrees. */ angle?: string; + /** `--selector` scope: when the user focused one element, narrow `requests` + * to that element if it animates, else to its animated descendants (so a + * static `.clip` wrapper resolves to the animated children under it). */ + scopeSelector?: string | null; } interface PageSample { @@ -140,14 +160,55 @@ function timelineDuration(page: import("puppeteer-core").Page): Promise }); } +// In the live DOM, decide which animated selectors fall under `scope`: read +// whether the scope exists and, for each candidate, whether it is the scope or a +// descendant of it. The pure decision (motionShotLayout.resolveShotSelectors) +// runs Node-side on the booleans this returns, so it stays unit-testable. +async function resolveScopeInBrowser( + page: import("puppeteer-core").Page, + scope: string, + candidates: string[], +): Promise { + const probe = await page.evaluate( + (scopeSel: string, cands: string[]) => { + let root: Element | null = null; + try { + root = document.querySelector(scopeSel); + } catch { + root = null; + } + const descendant = cands.map((sel) => { + if (!root) return false; + let el: Element | null = null; + try { + el = document.querySelector(sel); + } catch { + return false; + } + return !!el && (el === root || root.contains(el)); + }); + return { scopeExists: !!root, descendant }; + }, + scope, + candidates, + ); + const selectors = resolveShotSelectors( + scope, + candidates, + (_s, target) => probe.descendant[candidates.indexOf(target)] === true, + ); + return { selectors, scopeExists: probe.scopeExists }; +} + /** Render `projectDir`'s index headless, sample each element's motion as a 3D * onion-skin, screenshot to `outPath` (PNG). Returns the saved path. */ export async function captureMotionPathShot( projectDir: string, - requests: ShotRequest[], + requestsIn: ShotRequest[], outPath: string, opts: ShotOptions = {}, ): Promise { + let requests = requestsIn; const samples = Math.max(1, Math.min(60, opts.samples ?? 9)); const layout = opts.layout ?? "path"; const fit = opts.fit ?? true; @@ -170,6 +231,31 @@ export async function captureMotionPathShot( browserInstance = opened.browser; const { page, size } = opened; + // --selector scope: the focused element is often a STATIC wrapper (`.clip`) + // whose animated children carry the tweens. Resolve, in the live DOM, to the + // scope itself if it animates, else its animated descendants — so the shot + // works on the standard composition shape instead of erroring. + if (opts.scopeSelector) { + const resolved = await resolveScopeInBrowser( + page, + opts.scopeSelector, + requests.map((r) => r.selector), + ); + if (!resolved.scopeExists) { + throw new Error(`--shot: --selector '${opts.scopeSelector}' matched no element.`); + } + if (resolved.selectors.length === 0) { + const nearest = requests + .slice(0, 5) + .map((r) => r.selector) + .join(", "); + throw new Error( + `--shot: nothing animates under '${opts.scopeSelector}'. Nearest animated elements: ${nearest || "(none)"}.`, + ); + } + requests = resolved.selectors.map((selector) => ({ selector })); + } + const times = sampleTimes( await timelineDuration(page), samples, diff --git a/packages/cli/src/commands/motionShotLayout.test.ts b/packages/cli/src/commands/motionShotLayout.test.ts index 459b0b024b..a9fe7f6731 100644 --- a/packages/cli/src/commands/motionShotLayout.test.ts +++ b/packages/cli/src/commands/motionShotLayout.test.ts @@ -3,11 +3,43 @@ import { buildOnionSvg, fitTransform, parseAngle, + resolveShotSelectors, sampleTimes, stripCells, type OnionElement, } from "./motionShotLayout.js"; +describe("resolveShotSelectors", () => { + const animated = [".title", ".cube", ".floor"]; + + it("samples the scope itself when the scope animates", () => { + // .cube has its own tween → exact selection, no descendant fallback. + const got = resolveShotSelectors(".cube", animated, () => { + throw new Error("descendant check should not run when scope animates"); + }); + expect(got).toEqual([".cube"]); + }); + + it("falls back to animated descendants when the scope is a static wrapper", () => { + // .clip is the standard composition root: static, but every animated element + // lives under it → resolve to all of them (this is VERIFIED BUG #9). + const got = resolveShotSelectors(".clip", animated, () => true); + expect(got).toEqual([".title", ".cube", ".floor"]); + }); + + it("keeps only the descendants that are actually under the scope", () => { + const underPanel = new Set([".title", ".floor"]); + const got = resolveShotSelectors(".panel", animated, (_scope, target) => + underPanel.has(target), + ); + expect(got).toEqual([".title", ".floor"]); + }); + + it("returns [] when nothing animates under the scope (caller errors)", () => { + expect(resolveShotSelectors(".empty", animated, () => false)).toEqual([]); + }); +}); + describe("sampleTimes", () => { it("spreads N equal-time steps across the full duration", () => { expect(sampleTimes(4, 5, null, null)).toEqual([0, 1, 2, 3, 4]); diff --git a/packages/cli/src/commands/motionShotLayout.ts b/packages/cli/src/commands/motionShotLayout.ts index a707458109..7040f84c34 100644 --- a/packages/cli/src/commands/motionShotLayout.ts +++ b/packages/cli/src/commands/motionShotLayout.ts @@ -57,6 +57,30 @@ export function parseAngle(a?: string): Camera { return { yaw: Number.isFinite(y) ? y! : 0, pitch: Number.isFinite(p) ? p! : 0 }; } +/** Resolve which animated selectors a `--shot --selector SCOPE` should sample. + * + * The scope element is often a STATIC wrapper (the standard `.clip` root) whose + * animated CHILDREN carry the tweens — so a literal match against animated + * targets finds nothing. We fall back to the animated descendants of the scope: + * + * 1. scope itself is animated → sample just scope (exact selection) + * 2. scope is static but has animated → sample those descendants + * descendants (e.g. `.clip` wrapper) + * 3. scope contains nothing animated → sample [] (caller errors, naming + * the nearest animated elements) + * + * `isDescendant(scope, target)` is supplied by the caller (DOM-aware in the + * browser); kept as a param so this decision is pure and unit-testable. + */ +export function resolveShotSelectors( + scope: string, + animated: string[], + isDescendant: (scope: string, target: string) => boolean, +): string[] { + if (animated.includes(scope)) return [scope]; + return animated.filter((target) => isDescendant(scope, target)); +} + /** N equal-time sample points across [from?, to?] within [0, dur]. */ export function sampleTimes( dur: number, From 638a14a20fda3fd6443f76a2000c5fc6ac36825d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 01:53:02 -0400 Subject: [PATCH 18/23] docs(skill): correct render contract + --shot vs snapshot - render binds the timeline by the root's data-composition-id; register window.__timelines under that exact key or the render freezes at t=0 (while --shot still looks fine). The #1 'looks fine, ships broken' trap. - --shot shows an element's motion, not painted structure; use 'snapshot --at' for masked type / metaballs / 3D / layers. --- skills/hyperframes-motion/SKILL.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/skills/hyperframes-motion/SKILL.md b/skills/hyperframes-motion/SKILL.md index 78732664d3..0478026b62 100644 --- a/skills/hyperframes-motion/SKILL.md +++ b/skills/hyperframes-motion/SKILL.md @@ -47,9 +47,11 @@ Use a versioned loop: Promotion rule: a lesson belongs in the skill only if it is reusable across prompts or explains a high-severity failure mode. Do not paste hidden prompt details, judge labels, target logos, one-off coordinates, or benchmark tricks into the skill. Write the lesson as **tell → fix → verify** so future agents can apply it to new motion, not just replay an eval answer. -**Renderable timeline contract.** A scored/rendered composition has one source of time: the paused seekable timeline HyperFrames drives. Do not include `requestAnimationFrame`, elapsed-time preview loops, timers, or pseudo timelines that keep animating during render. Register the real timeline under `window.__timelines[compositionId]` when possible and expose consistent `duration`, `time`, `seek`, and `progress` semantics; previews can wrap the same seek surface, but they must not fight it. +**Renderable timeline contract.** A scored/rendered composition has one source of time: the paused seekable timeline HyperFrames drives. Do not include `requestAnimationFrame`, elapsed-time preview loops, timers, or pseudo timelines that keep animating during render. Register the real timeline under `window.__timelines[compositionId]` and expose consistent `duration`, `time`, `seek`, and `progress` semantics; previews can wrap the same seek surface, but they must not fight it. -**`onUpdate` / `modifiers` ARE seek-safe here — but only if pure.** The engine renders each frame with `timeline.totalTime(t, false)` (verify in `packages/core/src/runtime/adapters/gsap.ts`: it nudges `+0.001` with events suppressed, then seeks to `t` with **`suppressEvents: false`**). So GSAP `onUpdate` and `modifiers:{}` callbacks **do fire on seek** — per-frame DOM writes they drive (morphing an SVG `d` / `points`, a `textContent` count-up, a path-follower) update correctly. The common GSAP folklore that *"onUpdate doesn't run when you seek"* does **not** apply to HyperFrames; do not avoid the technique on that basis. The real requirement: the callback must be a **pure, idempotent function of the timeline's current value/progress** — recompute from the tween value every call; never accumulate, read a frame delta, or touch wall-clock — because the engine nudges then re-renders and seeks arrive at arbitrary, repeated, out-of-order times. `modifiers:{}` is naturally pure and is the safest default; reserve `onUpdate` for writes that aren't a single tweened property. (What still freezes under seek: `requestAnimationFrame` / elapsed-time loops, timers, and anything not on the registered paused timeline.) **Verify by seeking** the paused timeline backward and forward at several times in real headless Chrome and asserting the rendered attribute/text actually changed and is identical across back-and-forth seeks — a Node-only geometry self-check proves the math is right, not that the engine ever calls it. +**The render path binds the timeline by `data-composition-id` — this is the #1 "looks fine, ships broken" trap.** The snapshot/render runtime resolves the root's `data-composition-id` and seeks `window.__timelines[]`; if the `.clip` root has no `data-composition-id`, or the timeline is registered under a different key (e.g. the loose `window.__timelines = { main: tl }`), the render binds **nothing** and emits the static build-time DOM **frozen at `t=0`**. Critically, `--shot` seeks `window.__timelines` directly so it renders the motion **fine** — the two paths **disagree silently**, and source/`--shot` looking correct does not mean the real render works. So: put `data-composition-id=""` on the `.clip` root and register the timeline under that **exact id** — `window.__timelines[""] = tl` (or `window.__timelines = { "": tl }`); the key must equal the root's `data-composition-id`, or the render binds nothing. Confirm with the real render/`validate`/`snapshot` path, not only `--shot`. + +**`onUpdate` / `modifiers` ARE seek-safe here — but only if pure.** The engine renders each frame with `timeline.totalTime(t, false)` (verify in `packages/core/src/runtime/adapters/gsap.ts`: it nudges `+0.001` with events suppressed, then seeks to `t` with **`suppressEvents: false`**). So GSAP `onUpdate` and `modifiers:{}` callbacks **do fire on seek** — per-frame DOM writes they drive (morphing an SVG `d` / `points`, a `textContent` count-up, a path-follower) update correctly. The common GSAP folklore that _"onUpdate doesn't run when you seek"_ does **not** apply to HyperFrames; do not avoid the technique on that basis. The real requirement: the callback must be a **pure, idempotent function of the timeline's current value/progress** — recompute from the tween value every call; never accumulate, read a frame delta, or touch wall-clock — because the engine nudges then re-renders and seeks arrive at arbitrary, repeated, out-of-order times. `modifiers:{}` is naturally pure and is the safest default; reserve `onUpdate` for writes that aren't a single tweened property. (What still freezes under seek: `requestAnimationFrame` / elapsed-time loops, timers, and anything not on the registered paused timeline.) **Verify by seeking** the paused timeline backward and forward at several times in real headless Chrome and asserting the rendered attribute/text actually changed and is identical across back-and-forth seeks — a Node-only geometry self-check proves the math is right, not that the engine ever calls it. Three corollaries that bite repeatedly: (1) **Drive followers from global time, not construction order.** A path-follower, a count-up, or a derived value must be a pure closed-form function of `tl.time()` (in one master `onUpdate`) — never `parseFloat(el.style.…)` of another tween's rendered output, and never tied to one child tween's local progress; those resolve differently under arbitrary seek order. (2) **Own `t=0` explicitly.** `fromTo`/`from` immediate-render their _from_-state at **build** time, so after construction the DOM sits on the last-built tween's start, not the composition's `t=0`; give every animated element a timeline-resident zero state (a `set()`/tween at position 0) so seeking back to 0 reproduces frame 1 exactly. (3) **Animate values through a real channel,** not a discrete `textContent`/`set()` swap with no tween — a stepped value with no animated channel has nothing for the engine to interpolate on seek. @@ -61,7 +63,9 @@ npx hyperframes motion --json # machine-readable (agents) npx hyperframes motion --shot path.png # onion-skin screenshot (3D, all channels) ``` -**The shot is ground truth.** Numbers say what you wrote; `--shot ` shows what it does. It seeks the **live timeline** at N steps and renders the **real element** at each — true-3D ghosts (foreshortened/edge-on for rotationX/Y/Z + z, sized by scale, filled with its colour, faded by opacity; path coloured by time, ghost spacing = velocity). It reads what actually rendered, so it catches eased / dynamic / 3D motion the numbers hide. Works on **any** animated element. Author → `--shot` → open the PNG → check against your target, before render. +**The shot is ground truth.** Numbers say what you wrote; `--shot ` shows what it does. It seeks the **live timeline** at N steps and renders the **real element** at each — true-3D ghosts (foreshortened/edge-on for rotationX/Y/Z + z, sized by scale, filled with its colour, faded by opacity; path coloured by time, ghost spacing = velocity). It reads what actually rendered, so it catches eased / dynamic / 3D motion the numbers hide. Works on **any** animated element (`--selector '.clip'` falls back to sampling the root's animated descendants). Author → `--shot` → open the PNG → check against your target, before render. + +**`--shot` is ground truth for an element's MOTION (path, transform, 3D pose) — not for painted structure.** It onion-skins one element's bounding box, so it cannot show masked-type letterforms entering, an SVG metaball/goo merge, multi-face cube shading, exploded-layer legibility, or multi-card flythrough composition — for those it draws abstract marker boxes that look broken even when the piece is fine. For structural / multi-element / filter-painted mechanisms, verify with **`npx hyperframes snapshot --at t1,t2,t3`**, which seeks the live timeline and screenshots the **real painted stage** (all elements, SVG filters applied) into a contact sheet. Use `--shot` for "how does this element move", `snapshot --at` for "what does the frame actually look like". If render diagnostics cannot run, be honest about it. Do not claim visual verification from source inspection alone; record the exact sample times a reviewer should inspect, the expected visual evidence at each beat, what was checked statically, and what remains unverified. From 7dad5ab2b7f7f3a99ee7cfd4475f3000b4374d5a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:09:46 -0400 Subject: [PATCH 19/23] fix(cli): info --json reports correct resolution + duration Derive orientation from width/height (1920x1080 was mislabeled portrait) and read duration from data-duration instead of a default. --- packages/cli/src/commands/info.test.ts | 33 ++++++++++++++++++++++++++ packages/cli/src/commands/info.ts | 26 +++++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/info.test.ts diff --git a/packages/cli/src/commands/info.test.ts b/packages/cli/src/commands/info.test.ts new file mode 100644 index 0000000000..1386216100 --- /dev/null +++ b/packages/cli/src/commands/info.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { orientation, durationFromHtml } from "./info.js"; + +describe("orientation", () => { + it("is landscape when width > height", () => { + expect(orientation(1920, 1080)).toBe("landscape"); + }); + + it("is portrait when height > width", () => { + expect(orientation(1080, 1920)).toBe("portrait"); + }); + + it("is square when width === height", () => { + expect(orientation(1080, 1080)).toBe("square"); + }); +}); + +describe("durationFromHtml", () => { + it("reads data-duration from the root composition element", () => { + const html = `
`; + expect(durationFromHtml(html, 5)).toBe(6); + }); + + it("reads data-duration regardless of attribute order", () => { + const html = `
`; + expect(durationFromHtml(html, 5)).toBe(8); + }); + + it("falls back to the computed timeline duration when no data-duration", () => { + const html = `
`; + expect(durationFromHtml(html, 5)).toBe(5); + }); +}); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index f5074d3757..f57d40f2a1 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -14,6 +14,25 @@ import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; +/** Derive orientation label from actual pixel dimensions. */ +export function orientation(width: number, height: number): "landscape" | "portrait" | "square" { + if (width > height) return "landscape"; + if (height > width) return "portrait"; + return "square"; +} + +/** + * Duration of the composition: prefer the root element's data-duration, + * fall back to the computed timeline end. + */ +export function durationFromHtml(html: string, fallback: number): number { + const match = + html.match(/data-composition-id[^>]*data-duration=["']([\d.]+)["']/) || + html.match(/data-duration=["']([\d.]+)["'][^>]*data-composition-id/); + const value = match?.[1] ? parseFloat(match[1]) : NaN; + return Number.isFinite(value) ? value : fallback; +} + function totalSize(dir: string): number { let total = 0; for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -56,6 +75,7 @@ export default defineCommand({ const width = widthMatch?.[1] ? parseInt(widthMatch[1], 10) : fallback.width; const height = heightMatch?.[1] ? parseInt(heightMatch[1], 10) : fallback.height; const resolution = `${width}x${height}`; + const duration = durationFromHtml(html, maxEnd); const size = totalSize(project.dir); const typeCounts: Record = {}; @@ -71,10 +91,10 @@ export default defineCommand({ JSON.stringify( withMeta({ name: project.name, - resolution: parsed.resolution, + resolution: orientation(width, height), width, height, - duration: maxEnd, + duration, elements: parsed.elements.length, tracks: tracks.size, types: typeCounts, @@ -89,7 +109,7 @@ export default defineCommand({ console.log(`${c.success("◇")} ${c.accent(project.name)}`); console.log(label("Resolution", resolution)); - console.log(label("Duration", `${maxEnd.toFixed(1)}s`)); + console.log(label("Duration", `${duration.toFixed(1)}s`)); console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`)); console.log(label("Tracks", `${tracks.size}`)); console.log(label("Size", formatBytes(size))); From 6c219713c0d86224ba3e32f75baf048faef3fe7c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:09:46 -0400 Subject: [PATCH 20/23] fix(cli): inspect suppresses intended-clip/3D layout false positives text_box_overflow no longer fires on content clipped by an overflow:hidden ancestor (odometer reels); text_occluded no longer fires on preserve-3d back faces. Genuine overflow/occlusion still reported. --- .../cli/src/commands/layout-audit.browser.js | 36 +++++++++++++++++-- packages/cli/src/utils/layoutAudit.test.ts | 17 +++++++++ packages/cli/src/utils/layoutAudit.ts | 12 +++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 76ca106a8a..119d332733 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -310,6 +310,17 @@ }; } + // An ancestor (up to and including `stopAt`) that clips its overflow makes any + // text spilling past it invisible — that clipping IS the layout mechanism + // (odometer/ticker reels, masked windows), not a defect to report. + function clippedByAncestor(element, stopAt) { + for (let current = element; current; current = current.parentElement) { + if (current !== element && clipsOverflow(getComputedStyle(current))) return true; + if (current === stopAt) break; + } + return false; + } + function textOverflowIssues(element, root, rootRect, time, tolerance) { const textRect = textRectFor(element); if (!textRect) return []; @@ -320,7 +331,11 @@ const container = nearestConstraint(element, root, rootRect); const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect()); const containerOverflow = overflowFor(textRect, containerRect, tolerance); - if (containerOverflow && !hasAllowOverflowFlag(element)) { + if ( + containerOverflow && + !hasAllowOverflowFlag(element) && + !clippedByAncestor(element, container) + ) { const style = getComputedStyle(element); issues.push({ code: "text_box_overflow", @@ -520,12 +535,29 @@ return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element); } + // The nearest ancestor establishing a 3D rendering context, or null. Elements + // sharing one are depth-sorted in 3D, so a "covering" hit is legitimate + // perspective (e.g. the back face of a preserve-3d cube), not a 2D overlap. + function preserve3dContext(element) { + for (let current = element; current; current = current.parentElement) { + const ts = getComputedStyle(current).transformStyle; + if (ts === "preserve-3d") return current; + } + return null; + } + + function sharedPreserve3d(a, b) { + const ctx = preserve3dContext(a); + return !!ctx && ctx === preserve3dContext(b); + } + // The opaque element painted over (x, y), or null when the topmost element - // there is related to the text or non-opaque. + // there is related to the text, non-opaque, or sharing a 3D context with it. function occluderAt(element, x, y) { if (typeof document.elementFromPoint !== "function") return null; const hit = document.elementFromPoint(x, y); if (!isForeignElement(element, hit)) return null; + if (sharedPreserve3d(element, hit)) return null; return isOpaqueOccluder(hit) ? hit : null; } diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index f748992053..6c4ffc9704 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -3,6 +3,7 @@ import { buildLayoutSampleTimes, buildTransitionSampleTimes, computeOverflow, + overflowValueClips, collapseStaticLayoutIssues, limitLayoutIssues, mergeSampleTimes, @@ -165,6 +166,22 @@ describe("layoutAudit helpers", () => { expect(formatted).toContain("t=1-5s (3 samples)"); }); + // The clip rule that suppresses the odometer/ticker false positive: text + // spilling past an `overflow:hidden` reel window is the mechanism, not a bug. + it("treats clipping overflow values as masking (suppress) and visible as not (still report)", () => { + // Intended clipping — the queued digit rows of a reel are masked here. + expect(overflowValueClips("hidden")).toBe(true); + expect(overflowValueClips("clip")).toBe(true); + expect(overflowValueClips("auto")).toBe(true); + expect(overflowValueClips("scroll")).toBe(true); + // Genuine overflow — nothing masks the text, so it must STILL be reported. + expect(overflowValueClips("visible")).toBe(false); + expect(overflowValueClips("clip visible")).toBe(false); + expect(overflowValueClips("")).toBe(false); + expect(overflowValueClips(null)).toBe(false); + expect(overflowValueClips(undefined)).toBe(false); + }); + it("limits returned issues by severity before truncating", () => { const limited = limitLayoutIssues( [ diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 2826c1dcf5..70b00deb9c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -95,6 +95,18 @@ export function computeOverflow( return Object.keys(overflow).length > 0 ? overflow : null; } +/** + * Whether a computed `overflow*` value clips its box. Mirrors the rule the + * browser audit (layout-audit.browser.js) uses to decide that text spilling + * past such an ancestor is intentionally masked (odometer/ticker reels) rather + * than a `text_box_overflow` defect. Kept here as the one unit-testable seam of + * that suppression: only `visible` (and the `clip visible` no-op) must NOT clip + * — every clipping value must, or real masked overflow gets reported as a bug. + */ +export function overflowValueClips(value: string | null | undefined): boolean { + return !!value && value !== "visible" && value !== "clip visible"; +} + export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary { const errorCount = issues.filter((issue) => issue.severity === "error").length; const warningCount = issues.filter((issue) => issue.severity === "warning").length; From c5d6b1a136d7f4cf7aac376b733d7fcd65be1698 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:09:46 -0400 Subject: [PATCH 21/23] feat(cli): snapshot guarantees a tail frame + adds --angle Always capture a readable end-of-timeline frame (t=duration rendered blank); --angle rotates the real painted stage for orthogonal 3D occlusion/depth verification. --- packages/cli/src/commands/snapshot.test.ts | 61 +++++++++ packages/cli/src/commands/snapshot.ts | 144 +++++++++++++++++++-- 2 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/snapshot.test.ts diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts new file mode 100644 index 0000000000..0537f1705d --- /dev/null +++ b/packages/cli/src/commands/snapshot.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js"; + +describe("tailFrameTime", () => { + it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => { + // Verified on the V4 3D artifact: t=8.0 of an 8s clip rendered blank white, + // t=7.76 rendered the final hero. 8 - 8*0.03 = 7.76. + expect(tailFrameTime(8)).toBeCloseTo(7.76, 5); + }); + + it("uses a 50ms floor for short clips", () => { + expect(tailFrameTime(1)).toBeCloseTo(0.95, 5); // 1 - 0.05 (floor beats 3%) + }); + + it("never goes negative", () => { + expect(tailFrameTime(0)).toBe(0); + }); +}); + +describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => { + it("default frames: last point is the readable tail, never exact duration", () => { + const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 }); + expect(times).toHaveLength(5); + expect(times[0]).toBe(0); + expect(times[times.length - 1]).toBeCloseTo(7.76, 5); + expect(times[times.length - 1]).toBeLessThan(8); // not the blank exact-end + expect(appendedTail).toBe(false); + }); + + it("single frame samples the midpoint", () => { + expect(computeSnapshotTimes(8, { frames: 1 }).times).toEqual([4]); + }); + + it("explicit --at: keeps the user's times AND appends an end-of-timeline frame", () => { + const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 2, 3] }); + expect(times.slice(0, 3)).toEqual([1, 2, 3]); + expect(times[times.length - 1]).toBeCloseTo(7.76, 5); + expect(appendedTail).toBe(true); + }); + + it("explicit --at: does not double-add when the user already sampled the tail", () => { + const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 7.76] }); + expect(times).toEqual([1, 7.76]); + expect(appendedTail).toBe(false); + }); + + it("explicit --at: a sample at exact duration counts as the tail (no append)", () => { + const { appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 8] }); + expect(appendedTail).toBe(false); + }); + + it("respects includeEnd:false opt-out for --at", () => { + const { times, appendedTail } = computeSnapshotTimes(8, { + frames: 5, + at: [1, 2], + includeEnd: false, + }); + expect(times).toEqual([1, 2]); + expect(appendedTail).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 81b947d5af..a4a606a26e 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -9,8 +9,42 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { c } from "../ui/colors.js"; import { findFFmpeg } from "../browser/ffmpeg.js"; +import { parseAngle, type Camera } from "./motionShotLayout.js"; import type { Example } from "./_examples.js"; +// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so +// the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]: +// snapshot only captured the composition's own head-on camera, so 3D depth / +// occlusion couldn't be verified). Same approach as motionShot's orbit camera: +// make the composition root + its ancestor chain preserve-3d, strip intermediate +// perspective, put one perspective on the root's parent (the lens) and rotate +// the root — works on any composition shape (no #stage assumption). +// +// Kept as a self-contained copy of motionShot.ts's `applyOrbitCamera` because +// that one is module-private; this is ~15 lines and sharing it would mean +// touching motionShot.ts (out of scope for this change). +function orbitStageSource(): string { + return `function(cam) { + var root = document.querySelector("[data-composition-id]") + || document.querySelector("#stage") + || document.body.firstElementChild + || document.body; + var n = root; + while (n && n !== document.body) { + n.style.transformStyle = "preserve-3d"; + n.style.perspective = "none"; + n = n.parentElement; + } + root.style.transformStyle = "preserve-3d"; + root.style.perspective = "none"; + root.style.transformOrigin = "50% 50%"; + root.style.transform = "rotateX(" + cam.pitch + "deg) rotateY(" + cam.yaw + "deg)"; + var lens = root.parentElement || document.body; + lens.style.perspective = "1600px"; + lens.style.perspectiveOrigin = "50% 50%"; + }`; +} + /** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the * default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological * clip (corrupt media, stalled network mount, codec edge case) cannot wedge @@ -86,15 +120,69 @@ async function extractVideoFrameToBuffer( export const examples: Example[] = [ ["Capture 5 key frames from a composition", "snapshot capture"], ["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"], + ["View the 3D stage from an isometric angle", "snapshot capture --angle iso"], ]; +/** + * Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime + * treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D + * artifact: t=8.0 of an 8s clip was pure white, t=7.76 showed the final hero). + * So the "final frame" must be sampled just-before-end. The blank tail observed + * spanned the last ~2.5% of the timeline, hence a 3%-of-duration nudge (floored + * at 50ms so very short clips still back off a readable amount). + */ +export function tailFrameTime(duration: number): number { + return Math.max(0, duration - Math.max(0.05, duration * 0.03)); +} + +/** + * Pick the seek positions to screenshot. Pure so the "tail is always captured" + * guarantee is unit-testable (FINDING [7]: evenly-spaced --at times skipped the + * final beat and short hero beats with no signal). + * + * - No --at: evenly-spaced frames, but the LAST point is moved off the exact + * duration to `tailFrameTime` so it isn't blank. + * - With --at: the user's exact times are honoured, plus a guaranteed + * end-of-timeline frame appended (unless `includeEnd` is false), so the tail + * is never silently skipped. A near-duplicate of the tail is not added twice. + * + * `appendedTail` flags that the readable-tail frame was added on top of the + * caller's request — used to warn that short sub-interval beats between samples + * may still be missed and need explicit --at. + */ +export function computeSnapshotTimes( + duration: number, + opts: { frames: number; at?: number[]; includeEnd?: boolean }, +): { times: number[]; appendedTail: boolean } { + const includeEnd = opts.includeEnd !== false; + const tail = tailFrameTime(duration); + const round = (t: number) => Math.round(t * 1000) / 1000; + + if (opts.at?.length) { + const times = opts.at.map(round); + // Only append if the user didn't already sample at/near the readable tail. + const hasTail = times.some((t) => Math.abs(t - tail) < 0.05 || t >= duration); + if (includeEnd && duration > 0 && !hasTail) { + return { times: [...times, round(tail)], appendedTail: true }; + } + return { times, appendedTail: false }; + } + + const n = opts.frames; + if (n <= 1) return { times: [round(duration / 2)], appendedTail: false }; + const times = Array.from({ length: n }, (_, i) => (i / (n - 1)) * duration); + // Replace the final (exact-duration, blank) point with the readable tail. + if (includeEnd) times[times.length - 1] = tail; + return { times: times.map(round), appendedTail: false }; +} + /** * Render key frames from a composition as PNG screenshots. * The agent can Read these to verify its output visually. */ async function captureSnapshots( projectDir: string, - opts: { frames?: number; timeout?: number; at?: number[] }, + opts: { frames?: number; timeout?: number; at?: number[]; angle?: Camera; includeEnd?: boolean }, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); @@ -214,12 +302,25 @@ async function captureSnapshots( return []; } - // Calculate seek positions — explicit timestamps or evenly spaced - const positions: number[] = opts.at?.length - ? opts.at - : numFrames === 1 - ? [duration / 2] - : Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration); + // Calculate seek positions — explicit timestamps or evenly spaced, always + // including a readable end-of-timeline frame (FINDING [7]). + const { times: positions, appendedTail } = computeSnapshotTimes(duration, { + frames: numFrames, + at: opts.at, + includeEnd: opts.includeEnd, + }); + if (appendedTail) { + console.log( + ` ${c.dim(`Note: added an end-of-timeline frame at ${positions[positions.length - 1]!.toFixed(2)}s. Short beats between your --at times may still be skipped — pass them explicitly.`)}`, + ); + } + + // Orthogonal camera (FINDING [10]) — re-applied after each seek inside the + // loop, since renderSeek may touch the stage's inline transform. + const cameraExpr = + opts.angle && (opts.angle.yaw !== 0 || opts.angle.pitch !== 0) + ? `(${orbitStageSource()})(${JSON.stringify(opts.angle)})` + : null; const snapshotDir = join(projectDir, "snapshots"); mkdirSync(snapshotDir, { recursive: true }); @@ -307,6 +408,8 @@ async function captureSnapshots( requestAnimationFrame(function() { requestAnimationFrame(finish); }); })`); + if (cameraExpr) await page.evaluate(cameraExpr); + if (injectVideoFramesBatch && syncVideoFrameVisibility) { const active = await page.evaluate((t: number) => { return Array.from(document.querySelectorAll("video[data-start]")) @@ -424,6 +527,17 @@ export default defineCommand({ description: "Ms to wait for runtime to initialize (default: 5000)", default: "5000", }, + angle: { + type: "string", + description: + "Orthogonal 3D camera for depth/occlusion checks: a preset (front|iso|top|side) or 'yaw,pitch' degrees. Tilts the whole stage before screenshotting (real pixels, not bbox markers).", + }, + end: { + type: "boolean", + description: + "Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.", + default: true, + }, describe: { type: "string", description: @@ -451,13 +565,25 @@ export default defineCommand({ ? null : String(args.describe); + const camera = args.angle ? parseAngle(String(args.angle)) : undefined; + const label = atTimestamps ? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]` : `${frames} frames`; - console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`); + const angleLabel = + camera && (camera.yaw !== 0 || camera.pitch !== 0) + ? ` ${c.dim(`(angle yaw ${camera.yaw}° pitch ${camera.pitch}°)`)}` + : ""; + console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}${angleLabel}`); try { - const paths = await captureSnapshots(project.dir, { frames, timeout, at: atTimestamps }); + const paths = await captureSnapshots(project.dir, { + frames, + timeout, + at: atTimestamps, + angle: camera, + includeEnd: args.end !== false, + }); if (paths.length === 0) { console.log( From e8c6451cfabe29b035cdf75e2881b0f4227e5f2e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:09:47 -0400 Subject: [PATCH 22/23] fix(core): motion surfaces staggered collection tweens honestly A staggered selector-collection reveal printed as a flat X->X no-op; it now shows real from/to values and the stagger, so 'does it move' reads true. --- .../parsers/__goldens__/complex.parsed.json | 42 ++++++++++ .../gsapParserAcorn.motionEval.test.ts | 48 +++++++++++ packages/core/src/parsers/gsapParserAcorn.ts | 80 +++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json index af592ef9dd..d1032f39b9 100644 --- a/packages/core/src/parsers/__goldens__/complex.parsed.json +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -14,6 +14,27 @@ "stagger": "__raw:0.055" }, "resolvedStart": 0.05, + "keyframes": { + "format": "percentage", + "keyframes": [ + { + "percentage": 0, + "properties": { + "y": 46, + "opacity": 0, + "stagger": 0.055 + } + }, + { + "percentage": 100, + "properties": { + "y": 0, + "opacity": 1, + "stagger": 0.055 + } + } + ] + }, "id": ".headline span-from-50" }, { @@ -56,6 +77,27 @@ "stagger": "__raw:0.08" }, "resolvedStart": 0.16, + "keyframes": { + "format": "percentage", + "keyframes": [ + { + "percentage": 0, + "properties": { + "scaleX": 0, + "opacity": 0, + "stagger": 0.08 + } + }, + { + "percentage": 100, + "properties": { + "scaleX": 1, + "opacity": 1, + "stagger": 0.08 + } + } + ] + }, "id": ".ambient-line-from-160" } ], diff --git a/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts b/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts index 06b1eac933..db2e9ac9bb 100644 --- a/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts +++ b/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts @@ -117,6 +117,54 @@ describe("motion eval — collection aliases (#4)", () => { }); }); +// ── Bug: staggered collection tweens read as flat no-ops ────────────────────── + +describe("motion eval — staggered collection tweens are honest", () => { + it("surfaces real from/to + stagger for a staggered .from reveal (not a flat no-op)", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.from(".glyph", { yPercent: 120, stagger: 0.08, duration: 0.5 }, 0); + `); + const a = animations[0]!; + // Surfaced as keyframes so the `motion` text shows real per-element motion. + const kfs = a.keyframes?.keyframes ?? []; + expect(kfs.length).toBeGreaterThan(0); + // from() plays vars → rest: 120 → 0, a real non-no-op move. + expect(kfs[0]!.properties.yPercent).toBe(120); + expect(kfs.at(-1)!.properties.yPercent).toBe(0); + // The per-element stagger is noted on the surfaced keyframes. + expect(kfs.every((k) => k.properties.stagger === 0.08)).toBe(true); + // Per-element duration preserved; selector + extras untouched (round-trip safe). + expect(a.duration).toBe(0.5); + expect(a.targetSelector).toBe(".glyph"); + expect(a.extras?.stagger).toBeDefined(); + }); + + it("notes the stagger even when a .to lands on the rest pose (1→1 case)", () => { + // to(rest-pose) reads as 1→1; without the stagger note it looks like a no-op. + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to(".glyph", { scaleY: 1, scaleX: 1, yPercent: 0, duration: 0.18, + stagger: { each: 0.012, from: "center" } }, 0); + `); + const a = animations[0]!; + const kfs = a.keyframes?.keyframes ?? []; + expect(kfs.length).toBeGreaterThan(0); + // The collection still reads as animating: the per-element stagger is shown. + expect(kfs.every((k) => k.properties.stagger === 0.012)).toBe(true); + expect(a.duration).toBe(0.18); + }); + + it("leaves a non-staggered single tween untouched (no synthetic keyframes)", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, duration: 0.4 }, 0); + `); + expect(animations[0]!.targetSelector).toBe("#hero"); + expect(animations[0]!.keyframes).toBeUndefined(); + }); +}); + // ── Bug #11 / #5: dwell tweens & onUpdate proxy clarity ─────────────────────── describe("motion eval — dwell / proxy targets (#11, #5)", () => { diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 8f40a85ceb..cd5bf2c5eb 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -1131,6 +1131,82 @@ function tweenCallToAnimation( return anim; } +// ── Stagger annotation (read path) ──────────────────────────────────────────── + +/** + * Pull the per-element stagger amount out of a captured `extras.stagger` value. + * GSAP staggers are either a number (`0.08`) or an object (`{ each: 0.012, … }`). + * The value arrives here as the round-trip form `__raw:`; we only need + * the leading numeric / `each:` figure for the annotation. Returns undefined for + * non-numeric (function) staggers — there's nothing honest to print. + */ +function staggerAmount(raw: unknown): number | undefined { + if (typeof raw === "number") return raw; + if (typeof raw !== "string") return undefined; + const src = raw.startsWith("__raw:") ? raw.slice(6) : raw; + const m = /(?:each\s*:\s*)?(-?\d+(?:\.\d+)?)/.exec(src); + if (!m) return undefined; + const n = Number.parseFloat(m[1]!); + return Number.isFinite(n) ? n : undefined; +} + +/** Rest-pose value GSAP animates to/from for an unspecified endpoint. */ +function restValue(prop: string): number { + return prop === "opacity" || prop.startsWith("scale") ? 1 : 0; +} + +/** + * Honest from/to keyframes for a flat staggered tween, mirroring how a flat + * tween renders: `from()` plays vars → rest, `to()` plays rest → vars, `fromTo` + * plays its authored endpoints. A synthetic `stagger` channel rides on both + * keyframes so the per-element cascade is visible even when the tween lands on + * the rest pose (a 1→1 collection that would otherwise read as a no-op). + */ +function staggeredKeyframes( + anim: Omit, + each: number, +): GsapPercentageKeyframe[] { + const vars = { ...anim.properties }; + let from: Record; + let to: Record; + if (anim.method === "fromTo") { + from = { ...(anim.fromProperties ?? {}) }; + to = vars; + } else if (anim.method === "from") { + from = vars; + to = {}; + for (const k of Object.keys(vars)) to[k] = restValue(k); + } else { + from = { ...(anim.fromProperties ?? {}) }; + for (const k of Object.keys(vars)) if (from[k] === undefined) from[k] = restValue(k); + to = vars; + } + return [ + { percentage: 0, properties: { ...from, stagger: each } }, + { percentage: 100, properties: { ...to, stagger: each } }, + ]; +} + +/** + * Read-path honesty pass: a tween with a `stagger` targets a per-element + * collection that animates one element at a time. A flat staggered tween that + * lands on (or starts from) the rest pose otherwise renders as a misleading + * X→X no-op. Surface real from/to keyframes plus the per-element `stagger` + * amount so the reader can tell the collection DOES animate and roughly how. + * + * Read path only — the write/serialize path ignores `keyframes` and keeps the + * literal `extras.stagger` source, so round-trips are untouched. Keyframed / + * motionPath tweens already surface their motion, so they're left alone. + */ +function annotateStaggeredCollections(anims: Omit[]): void { + for (const anim of anims) { + if (anim.keyframes || anim.arcPath) continue; + const each = staggerAmount(anim.extras?.stagger); + if (each === undefined) continue; + anim.keyframes = { format: "percentage", keyframes: staggeredKeyframes(anim, each) }; + } +} + // ── Timeline position resolution ───────────────────────────────────────────── const GSAP_DEFAULT_DURATION = 0.5; @@ -1510,6 +1586,10 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { seedSetStates(rawAnims, collectGsapSetStates(ast, scope, targetBindings, script)); const labelDefs = collectAddLabelDefs(ast, timelineVar, scope, calls); resolveTimelinePositions(rawAnims, labelDefs); + // Honesty pass (read path only): make staggered collection tweens read as + // real per-element motion instead of a flat no-op. Done after positions so + // duration is settled; before ids so the annotation is part of the id. + annotateStaggeredCollections(rawAnims); const animations = assignStableIds(rawAnims); const timelineMatch = script.match( From 1ffaf5b5727782de517449b740e7a16bf223b3ac Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 15:38:10 -0400 Subject: [PATCH 23/23] =?UTF-8?q?feat(cli):=20motion=20--shot=20--ghost=20?= =?UTF-8?q?=E2=80=94=20rendered=20onion-skin=20for=20canvas/WebGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bbox-marker onion is blind to canvas-internal (WebGL) motion. --ghost captures the canvas pixels at each sample via an in-tick drawImage (seek → the composition's onUpdate render fires synchronously → read the GL buffer before it clears, so no preserveDrawingBuffer needed) and composites them as translucent ghosts (older fainter → newest solid). Errors clearly when there's no (the default onion already covers DOM/SVG). --- packages/cli/src/commands/motion.ts | 12 +- packages/cli/src/commands/motionShot.ts | 153 +++++++++++++++++- .../cli/src/commands/motionShotLayout.test.ts | 13 ++ packages/cli/src/commands/motionShotLayout.ts | 13 ++ 4 files changed, 189 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/motion.ts b/packages/cli/src/commands/motion.ts index 694adbf4af..ca6e93bdee 100644 --- a/packages/cli/src/commands/motion.ts +++ b/packages/cli/src/commands/motion.ts @@ -396,6 +396,7 @@ interface ShotArgs { to?: string; fit?: boolean; angle?: string; + ghost?: boolean; } // Every animated element qualifies — the onion samples the live element and shows @@ -429,7 +430,9 @@ async function runOnionShot( console.log(c.dim("--shot needs a project directory (not a single .html file).")); return true; } - if (requests.length === 0) { + // The rendered onion (--ghost) screenshots the whole painted stage, so it does + // not need an animated DOM element to sample — only the marker onion does. + if (requests.length === 0 && !args.ghost) { console.log(c.dim("--shot: no animated element to sample for the selection.")); return true; } @@ -441,6 +444,7 @@ async function runOnionShot( to: num(args.to), angle: args.angle, scopeSelector: args.selector ?? null, + ghost: args.ghost ?? false, }); console.log(`${c.success("◇")} onion-skin screenshot saved ${c.accent(saved)}`); console.log( @@ -548,6 +552,12 @@ export default defineCommand({ description: "--shot: zoom the motion to fill the frame (default true; --no-fit to disable).", default: true, }, + ghost: { + type: "boolean", + description: + "--shot: rendered onion-skin — composite the real canvas/WebGL frames as translucent ghosts (older fainter), instead of bbox markers. For the canvas-internal 3D motion the marker onion can't see (requires a ).", + default: false, + }, }, async run({ args }) { ensureDOMParser(); diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts index 8afea76627..f1e96f7190 100644 --- a/packages/cli/src/commands/motionShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -13,6 +13,7 @@ import { writeFileSync } from "node:fs"; import { buildOnionSvg, + ghostAlphas, parseAngle, resolveShotSelectors, sampleTimes, @@ -50,6 +51,10 @@ export interface ShotOptions { * to that element if it animates, else to its animated descendants (so a * static `.clip` wrapper resolves to the animated children under it). */ scopeSelector?: string | null; + /** Rendered ("ghost") onion-skin: capture the canvas pixels at each sample and + * composite them as translucent ghosts (older fainter → newest solid) — the + * canvas/WebGL motion the bbox-marker onion can't see. Requires a . */ + ghost?: boolean; } interface PageSample { @@ -88,6 +93,55 @@ function applyOrbitCamera(selectors: string[], cam: { yaw: number; pitch: number lens.style.perspectiveOrigin = "50% 50%"; } +// Runs IN THE BROWSER. Composite N real painted frames (data URLs) onto a black +// canvas with per-frame opacity (older fainter → newest solid) so canvas/WebGL +// motion reads as a rendered onion-skin trail. Returns the composite as a PNG +// data URL. Used by the `--ghost` mode. +function compositeGhostFrames( + frames: string[], + alphas: number[], + W: number, + H: number, + label: string, +): Promise { + return new Promise((resolve) => { + const cv = document.createElement("canvas"); + cv.width = W; + cv.height = H; + const ctx = cv.getContext("2d"); + if (!ctx) { + resolve(""); + return; + } + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, H); + let i = 0; + const step = () => { + if (i >= frames.length) { + ctx.globalAlpha = 1; + ctx.font = "600 22px ui-monospace, SFMono-Regular, Menlo, monospace"; + ctx.fillStyle = "#86c2ff"; + ctx.fillText(label, 24, 38); + resolve(cv.toDataURL("image/png")); + return; + } + const img = new Image(); + img.onload = () => { + ctx.globalAlpha = alphas[i] ?? 1; + ctx.drawImage(img, 0, 0, W, H); + i += 1; + step(); + }; + img.onerror = () => { + i += 1; + step(); + }; + img.src = frames[i] ?? ""; + }; + step(); + }); +} + // Launch headless Chrome, load the composition sized to its canvas, wait for the // timelines + fonts to be ready. Returns the browser (caller closes it), page, size. async function openCompositionPage( @@ -235,7 +289,7 @@ export async function captureMotionPathShot( // whose animated children carry the tweens. Resolve, in the live DOM, to the // scope itself if it animates, else its animated descendants — so the shot // works on the standard composition shape instead of erroring. - if (opts.scopeSelector) { + if (opts.scopeSelector && !opts.ghost) { const resolved = await resolveScopeInBrowser( page, opts.scopeSelector, @@ -263,6 +317,103 @@ export async function captureMotionPathShot( opts.to ?? null, ); + // ── Rendered ("ghost") onion-skin ────────────────────────────────────────── + // Screenshot the REAL painted stage at each sample and composite them as + // translucent ghosts. This is the onion-skin for canvas/WebGL motion the + // marker sampler is blind to (the markers project a bbox; the pixels are the + // motion). Works for any visual composition. + if (opts.ghost) { + if (camera.yaw !== 0 || camera.pitch !== 0) { + await page.evaluate( + applyOrbitCamera, + requests.map((r) => r.selector), + camera, + ); + } + // --ghost is the rendered onion for canvas/WebGL motion — the case the + // marker onion is blind to. For DOM/SVG the default --shot onion already + // shows transform motion, so require a canvas here rather than ship a + // page.screenshot path that fights the runtime's virtual-time rAF. + const hasCanvas = await page.evaluate(() => document.querySelectorAll("canvas").length > 0); + if (!hasCanvas) { + throw new Error( + "--ghost renders a canvas/WebGL motion trail, but this composition has no . Use the default --shot onion for DOM/SVG transform motion.", + ); + } + const frames: string[] = []; + for (const t of times) { + // In-tick capture: seek the timeline (fires the composition's onUpdate + // render synchronously) + nudge the three-adapter (hf-seek / __hfThreeTime + // / render hook), then drawImage the canvas in the SAME tick — before the + // browser clears the GL drawing buffer (works without preserveDrawingBuffer; + // page.screenshot can't see the GL buffer here). + const dataUrl = await page.evaluate((tt: number) => { + const w = window as unknown as { + __hfThreeTime?: number; + __hfThreeRender?: () => void; + __timelines?: Record< + string, + { + pause?: () => void; + seek?: (t: number) => void; + totalTime?: (t: number, s: boolean) => void; + } + >; + }; + Object.values(w.__timelines ?? {}).forEach((tl) => { + try { + tl.pause?.(); + if (typeof tl.totalTime === "function") tl.totalTime(tt, false); + else tl.seek?.(tt); + } catch { + /* best-effort */ + } + }); + try { + w.__hfThreeTime = tt; + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } })); + w.__hfThreeRender?.(); + } catch { + /* best-effort */ + } + const root = (document.querySelector("[data-composition-id]") ?? + document.body) as HTMLElement; + const rb = root.getBoundingClientRect(); + const off = document.createElement("canvas"); + off.width = Math.max(1, Math.round(rb.width)); + off.height = Math.max(1, Math.round(rb.height)); + const octx = off.getContext("2d"); + for (const cv of Array.from(document.querySelectorAll("canvas"))) { + const r = cv.getBoundingClientRect(); + try { + octx?.drawImage(cv, r.left - rb.left, r.top - rb.top, r.width, r.height); + } catch { + /* tainted / not ready — skip */ + } + } + return off.toDataURL("image/png"); + }, t); + frames.push(dataUrl); + } + const camLabelG = + camera.yaw === 0 && camera.pitch === 0 + ? "front" + : `yaw ${camera.yaw}° pitch ${camera.pitch}°`; + const labelG = `${camLabelG} · rendered onion · ${times.length} frames · t ${times[0]}–${times[times.length - 1]}s`; + const dataUrl = (await page.evaluate( + compositeGhostFrames, + frames, + ghostAlphas(frames.length), + size.width, + size.height, + labelG, + )) as string; + const b64 = String(dataUrl).replace(/^data:image\/png;base64,/, ""); + if (!b64) throw new Error("ghost composite returned no data"); + writeFileSync(outPath, Buffer.from(b64, "base64")); + return outPath; + } + // Orbit camera as its own step (keeps the sampler simple), only when angled. if (camera.yaw !== 0 || camera.pitch !== 0) { await page.evaluate( diff --git a/packages/cli/src/commands/motionShotLayout.test.ts b/packages/cli/src/commands/motionShotLayout.test.ts index a9fe7f6731..3924c85da9 100644 --- a/packages/cli/src/commands/motionShotLayout.test.ts +++ b/packages/cli/src/commands/motionShotLayout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildOnionSvg, fitTransform, + ghostAlphas, parseAngle, resolveShotSelectors, sampleTimes, @@ -9,6 +10,18 @@ import { type OnionElement, } from "./motionShotLayout.js"; +describe("ghostAlphas", () => { + it("ramps older→fainter, newest solid, monotonic increasing", () => { + expect(ghostAlphas(0)).toEqual([]); + expect(ghostAlphas(1)).toEqual([1]); + const a = ghostAlphas(5); + expect(a).toHaveLength(5); + expect(a[0]).toBeCloseTo(0.14, 3); // oldest faintest + expect(a[4]).toBe(1); // newest solid + for (let i = 1; i < a.length; i++) expect(a[i]).toBeGreaterThan(a[i - 1]!); + }); +}); + describe("resolveShotSelectors", () => { const animated = [".title", ".cube", ".floor"]; diff --git a/packages/cli/src/commands/motionShotLayout.ts b/packages/cli/src/commands/motionShotLayout.ts index 7040f84c34..fa88d84506 100644 --- a/packages/cli/src/commands/motionShotLayout.ts +++ b/packages/cli/src/commands/motionShotLayout.ts @@ -98,6 +98,19 @@ export function sampleTimes( }); } +/** Opacity ramp for the rendered ("ghost") onion-skin: older frames fainter, + * the newest frame solid, so the composite of real painted frames reads as a + * motion trail leading to the final pose. One alpha in [0,1] per sample. */ +export function ghostAlphas(n: number): number[] { + if (n <= 0) return []; + if (n === 1) return [1]; + const lo = 0.14; + return Array.from( + { length: n }, + (_, i) => Math.round((lo + (1 - lo) * (i / (n - 1))) * 1000) / 1000, + ); +} + /** Scale+centre transform that fits `pts` into a W×H frame (with padding). */ export function fitTransform( pts: Pt[],