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 1/9] =?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 6/9] 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 `