import { defineCommand } from "citty"; import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve, dirname, basename, join, relative, sep } 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 keyframes", "hyperframes keyframes --selector '#puck-a'"], ["Runtime-aware hint for CSS/Anime compositions", "hyperframes keyframes --runtime all"], ]; // ── 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; /** 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. */ 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 CssKeyframeStop { selector: string; declarations: string[]; } interface SurfacedCssKeyframes { name: string; selectors: string[]; keyframes: CssKeyframeStop[]; } interface SurfacedAnimeAnimation { kind: "animation" | "timeline"; targets: string[]; durations: Array; registered: boolean; } interface SurfacedComposition { composition: string; source: string; tweens: SurfacedTween[]; /** Multi-stroke traces: targets with ≥2 drawn position strokes, composited. */ traces: SurfacedTrace[]; cssKeyframes: SurfacedCssKeyframes[]; anime: SurfacedAnimeAnimation[]; } // ── 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 inlineStyleText(html: string): string { const doc = new DOMParser().parseFromString(html, "text/html"); return Array.from(doc.querySelectorAll("style")) .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); attachComposedAncestors(tweens, html); return { composition: label, source, tweens, traces: groupTraces(tweens), cssKeyframes: surfaceCssKeyframes(inlineStyleText(html)), anime: surfaceAnime(inlineScriptText(html)), }; } // ── CSS / Anime extraction ─────────────────────────────────────────────────── function readBalancedBlock(text: string, openBrace: number): { body: string; end: number } | null { if (text[openBrace] !== "{") return null; let depth = 0; for (let i = openBrace; i < text.length; i++) { const ch = text[i]; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { return { body: text.slice(openBrace + 1, i), end: i + 1 }; } } } return null; } function parseDeclarations(body: string): string[] { return body .split(";") .map((part) => part.trim()) .filter(Boolean); } function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { let out = ""; let cursor = 0; for (const range of [...ranges].sort((a, b) => a.start - b.start)) { out += text.slice(cursor, range.start); cursor = range.end; } return out + text.slice(cursor); } function collectKeyframeBlocks(css: string): { keyframes: Array<{ name: string; stops: CssKeyframeStop[] }>; ranges: Array<{ start: number; end: number }>; } { const keyframes: Array<{ name: string; stops: CssKeyframeStop[] }> = []; const ranges: Array<{ start: number; end: number }> = []; const re = /@keyframes\s+([a-zA-Z0-9_-]+)/g; let match: RegExpExecArray | null; while ((match = re.exec(css))) { const open = css.indexOf("{", re.lastIndex); const block = open >= 0 ? readBalancedBlock(css, open) : null; if (!block) continue; ranges.push({ start: match.index, end: block.end }); const stops: CssKeyframeStop[] = []; const stopRe = /([^{}]+)\{([^{}]*)\}/g; let stop: RegExpExecArray | null; while ((stop = stopRe.exec(block.body))) { stops.push({ selector: stop[1]!.trim().replace(/\s+/g, " "), declarations: parseDeclarations(stop[2]!), }); } keyframes.push({ name: match[1]!, stops }); } return { keyframes, ranges }; } function stripQuotes(raw: string): string { return raw.trim().replace(/^["']|["']$/g, ""); } // `animation-name: foo, bar;` — each comma-separated part is a full keyframes name. function addAnimationNameDeclNames(body: string, knownNames: Set, out: Set): void { const nameRe = /animation-name\s*:\s*([^;]+)/g; let nameMatch: RegExpExecArray | null; while ((nameMatch = nameRe.exec(body))) { for (const raw of nameMatch[1]!.split(",")) { const name = stripQuotes(raw); if (knownNames.has(name)) out.add(name); } } } // `animation: foo 2s ease, bar 1s;` shorthand — the name is one whitespace-separated // token among duration/timing-function/etc, so every token has to be checked. function addAnimationShorthandNames(body: string, knownNames: Set, out: Set): void { const animationRe = /animation\s*:\s*([^;]+)/g; let animationMatch: RegExpExecArray | null; while ((animationMatch = animationRe.exec(body))) { for (const raw of animationMatch[1]!.split(",")) { for (const token of raw.trim().split(/\s+/)) { const normalized = stripQuotes(token); if (knownNames.has(normalized)) out.add(normalized); } } } } function animationNamesFromDeclarations(body: string, knownNames: Set): string[] { const out = new Set(); addAnimationNameDeclNames(body, knownNames, out); addAnimationShorthandNames(body, knownNames, out); return [...out]; } // CSS comments would otherwise glue onto the next rule's selector: once the // @keyframes blocks between them are stripped, a `/* note */` sitting above an // @keyframes reattaches to the following rule (and corrupts both the printed // selector and the --shot querySelector). Drop comments up front. function stripCssComments(css: string): string { return css.replace(/\/\*[\s\S]*?\*\//g, " "); } function surfaceCssKeyframes(rawCss: string): SurfacedCssKeyframes[] { const css = stripCssComments(rawCss); if (!css.trim()) return []; const { keyframes, ranges } = collectKeyframeBlocks(css); if (keyframes.length === 0) return []; const selectorsByName = new Map>(); for (const kf of keyframes) selectorsByName.set(kf.name, new Set()); const knownNames = new Set(keyframes.map((kf) => kf.name)); const cssWithoutKeyframes = stripRanges(css, ranges); const ruleRe = /([^{}@]+)\{([^{}]*animation[^{}]*)\}/g; let rule: RegExpExecArray | null; while ((rule = ruleRe.exec(cssWithoutKeyframes))) { const selector = rule[1]!.trim().replace(/\s+/g, " "); if (!selector) continue; for (const name of animationNamesFromDeclarations(rule[2]!, knownNames)) { selectorsByName.get(name)?.add(selector); } } return keyframes.map((kf) => ({ name: kf.name, selectors: [...(selectorsByName.get(kf.name) ?? new Set())], keyframes: kf.stops, })); } function valuesFromProperty( script: string, property: "targets" | "duration", ): Array { const values: Array = []; const quoted = property + "\\s*:\\s*([\"'`])([^\"'`]+)\\1"; const numeric = property + "\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)"; const re = new RegExp(`${quoted}|${numeric}`, "g"); let match: RegExpExecArray | null; while ((match = re.exec(script))) { if (match[2] !== undefined) values.push(match[2]); else if (match[3] !== undefined) values.push(Number(match[3])); } return values; } function animeAddTargets(script: string): string[] { const values: string[] = []; const re = /\.add\s*\(\s*(["'`])([^"'`]+)\1/g; let match: RegExpExecArray | null; while ((match = re.exec(script))) values.push(match[2]!); return values; } function surfaceAnime(script: string): SurfacedAnimeAnimation[] { if (!/\banime\s*(?:\.(?:timeline|createTimeline))?\s*\(/.test(script)) return []; const registered = /__hfAnime[\s\S]*?\.push\s*\(/.test(script) || /__hfAnime\s*=/.test(script); const timelineCount = (script.match(/\banime\.(?:timeline|createTimeline)\s*\(/g) ?? []).length; const animationCount = (script.match(/\banime\s*\(/g) ?? []).length; const targets = [ ...valuesFromProperty(script, "targets").filter( (value): value is string => typeof value === "string", ), ...animeAddTargets(script), ]; const durations = valuesFromProperty(script, "duration"); const out: SurfacedAnimeAnimation[] = []; for (let i = 0; i < timelineCount; i++) { out.push({ kind: "timeline", targets, durations, registered }); } for (let i = 0; i < animationCount; i++) { out.push({ kind: "animation", targets, durations, registered }); } return out; } // 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 }); } // A drawn stroke must actually move across the canvas. A position tween whose // points never leave the start (an opacity/scale tween merely carrying a static // y) is not a pen stroke — exclude it so repeated in-place tweens don't // masquerade as a multi-stroke trace. function pathTravels(points: Array<{ x: number; y: number }>): boolean { const first = points[0]; if (!first) return false; return points.some((p) => Math.abs(p.x - first.x) > 0.5 || Math.abs(p.y - first.y) > 0.5); } // 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; if (!pathTravels(t.path)) continue; // in-place tween (e.g. opacity carrying a static y) is not a drawn stroke 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)}`); } 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(); } 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; ghost?: boolean; } // 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 addTraceSelectors(selectors: Set, cmp: SurfacedComposition): void { for (const tr of cmp.traces) selectors.add(tr.target); } function addTweenSelectors(selectors: Set, cmp: SurfacedComposition): void { for (const t of cmp.tweens) { if (t.method !== "set") selectors.add(t.target); } } function addCssKeyframeSelectors(selectors: Set, cmp: SurfacedComposition): void { for (const css of cmp.cssKeyframes) { for (const selector of css.selectors) { if (selector) selectors.add(selector); } } } function addAnimeSelectors(selectors: Set, cmp: SurfacedComposition): void { for (const anime of cmp.anime) { for (const selector of anime.targets) { if (selector) selectors.add(selector); } } } export function collectShotSelectors(comps: SurfacedComposition[]): Array<{ selector: string }> { const selectors = new Set(); for (const cmp of comps) { addTraceSelectors(selectors, cmp); addTweenSelectors(selectors, cmp); addCssKeyframeSelectors(selectors, cmp); addAnimeSelectors(selectors, cmp); } return [...selectors].map((selector) => ({ selector })); } // Guard checks that must pass before capturing the onion-skin shot. Returns the // message to print when a guard fails, or null when clear to proceed. function onionShotGuardError( projectDir: string | undefined, requests: Array<{ selector: string }>, ghost: boolean, ): string | null { if (!projectDir) return "--shot needs a project directory (not a single .html file)."; // 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 && !ghost) return "--shot: no animated element to sample for the selection."; return null; } function onionShotOptions(args: ShotArgs & { selector?: string }) { return { samples: num(args.samples) ?? 9, layout: (args.layout === "strip" ? "strip" : "path") as "strip" | "path", fit: args.fit ?? true, from: num(args.from), to: num(args.to), angle: args.angle, scopeSelector: args.selector ?? null, ghost: args.ghost ?? false, }; } function printOnionShotSaved(saved: string, elementCount: number): void { console.log(`${c.success("◇")} onion-skin screenshot saved ${c.accent(saved)}`); console.log( c.dim( ` ${elementCount} element${elementCount === 1 ? "" : "s"} · open it to verify the motion matches your target, then read the keyframes below.`, ), ); console.log(); } /** 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[], allComps: SurfacedComposition[], projectDir: string | undefined, entryFile: string | undefined, args: ShotArgs & { selector?: string }, ): Promise { const { captureMotionPathShot } = await import("./motionShot.js"); // 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 = collectShotSelectors(args.selector ? allComps : comps); const guardError = onionShotGuardError(projectDir, requests, args.ghost ?? false); if (guardError) { console.log(c.dim(guardError)); return true; } const saved = await captureMotionPathShot(projectDir!, requests, resolve(args.shot!), { ...onionShotOptions(args), entryFile, }); printOnionShotSaved(saved, requests.length); return false; } // Resolve the command target (a project dir or a single .html) into surfaced // compositions, applying the optional --selector filter. export function resolveScope(args: { target?: string; selector?: string }): { comps: SurfacedComposition[]; allComps: SurfacedComposition[]; projectName: string; projectDir: string | undefined; entryFile: string | undefined; } { const raw = args.target?.trim(); let comps: SurfacedComposition[]; let projectName: string; let projectDir: string | undefined; let entryFile: string | undefined; if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { const entryPath = resolve(raw); comps = [surfaceComposition(readFileSync(entryPath, "utf-8"), basename(entryPath), entryPath)]; projectName = basename(entryPath); projectDir = findProjectRoot(entryPath); entryFile = relative(projectDir, entryPath).split(sep).join("/"); } else { const project = resolveProject(raw); comps = collectCompositions(project.indexPath); 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); comps = comps .map((cmp) => ({ ...cmp, tweens: cmp.tweens.filter((t) => matches(t.target)), traces: cmp.traces.filter((tr) => matches(tr.target)), cssKeyframes: cmp.cssKeyframes.filter((kf) => kf.selectors.some(matches)), anime: cmp.anime.filter((a) => a.targets.some(matches)), })) .filter( (cmp) => cmp.tweens.length > 0 || cmp.traces.length > 0 || cmp.cssKeyframes.length > 0 || cmp.anime.length > 0, ); } return { comps, allComps, projectName, projectDir, entryFile }; } function findProjectRoot(entryPath: string): string { const entryDir = dirname(entryPath); let candidate = entryDir; for (;;) { if (existsSync(join(candidate, "index.html"))) return candidate; if (existsSync(join(candidate, ".git"))) return entryDir; const parent = dirname(candidate); if (parent === candidate) return entryDir; candidate = parent; } } function isEmptyComposition(cmp: SurfacedComposition): boolean { return ( cmp.tweens.length === 0 && cmp.traces.length === 0 && cmp.cssKeyframes.length === 0 && cmp.anime.length === 0 ); } // Print tweens not already shown as part of a trace (a drawn stroke, or its // internal pen-up jump). function printCompositionTweens(cmp: SurfacedComposition): void { 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 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); } } // Print one composition's traces + tweens (skipping strokes already shown in a trace). function printComposition(cmp: SurfacedComposition): void { if (isEmptyComposition(cmp)) return; console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`)); for (const tr of cmp.traces) printTrace(tr); printCompositionTweens(cmp); for (const cssKeyframes of cmp.cssKeyframes) printCssKeyframes(cssKeyframes); for (const anime of cmp.anime) printAnime(anime); } function printCssKeyframes(cssKeyframes: SurfacedCssKeyframes): void { const selectors = cssKeyframes.selectors.length ? cssKeyframes.selectors.join(", ") : "(no selector found)"; console.log( ` ${c.accent(`@keyframes ${cssKeyframes.name}`)}${c.dim(" css")} ${c.dim(selectors)}`, ); for (const stop of cssKeyframes.keyframes) { console.log(` ${c.dim(`${stop.selector} {${stop.declarations.join("; ")}}`)}`); } console.log(); } function printAnime(anime: SurfacedAnimeAnimation): void { const targets = anime.targets.length ? anime.targets.join(", ") : "(targets not parsed)"; const durations = anime.durations.length ? ` duration ${anime.durations.join(",")}` : ""; const registered = anime.registered ? "registered" : "not registered"; console.log( ` ${c.accent(`anime.${anime.kind}`)}${c.dim(` ${registered}`)} ${c.dim(`${targets}${durations}`)}`, ); console.log(); } // ── Command ────────────────────────────────────────────────────────────────── interface KeyframesCommandOptions { name: string; description: string; invocation: string; defaultRuntime: "gsap" | "css" | "anime" | "all"; } const defaultKeyframesCommand: KeyframesCommandOptions = { name: "keyframes", description: "See, debug, and refine keyframes — surface GSAP, CSS @keyframes, Anime.js, paths, and onion-shot diagnostics", invocation: "hyperframes keyframes", defaultRuntime: "all", }; function createKeyframesCommand(options: Partial = {}) { const commandOptions = { ...defaultKeyframesCommand, ...options }; return defineCommand({ meta: { name: commandOptions.name, description: commandOptions.description, }, args: { target: { type: "positional", description: "Project dir or composition .html", required: false, }, selector: { type: "string", description: "Only keyframes matching this CSS selector" }, runtime: { type: "string", description: "Runtime filter hint: gsap|css|anime|all. Surfaces GSAP tweens, CSS @keyframes, and Anime.js timelines when detectable.", }, 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, }, 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(); const runtime = normalizeRuntime(args.runtime, commandOptions.defaultRuntime); if (runtime === "css" || runtime === "anime") { console.log( c.dim( `${commandOptions.name}: ${runtime} output is a static authoring surface; use validate/render/snapshot to verify runtime adapter seekability.`, ), ); console.log(); } const { comps: rawComps, allComps, projectName, projectDir, entryFile } = resolveScope(args); const comps = filterCompositionsByRuntime(rawComps, runtime); // --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, allComps, projectDir, entryFile, args))) return; if (args.json) { console.log( JSON.stringify(withMeta({ project: projectName, runtime, compositions: comps }), null, 2), ); return; } const total = comps.reduce( (n, cmp) => n + cmp.tweens.length + cmp.cssKeyframes.length + cmp.anime.length, 0, ); if (total === 0) { console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no keyframes found")}`); return; } console.log( `${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} item${total === 1 ? "" : "s"}`)}`, ); console.log(); for (const cmp of comps) printComposition(cmp); console.log( c.dim( `Tip: edit the keyframes in source, then \`${commandOptions.invocation} --shot out.png\` to see the rendered motion.`, ), ); }, }); } function normalizeRuntime( runtime: unknown, fallback: KeyframesCommandOptions["defaultRuntime"], ): KeyframesCommandOptions["defaultRuntime"] { if (typeof runtime !== "string") return fallback; const normalized = runtime.toLowerCase(); return normalized === "gsap" || normalized === "css" || normalized === "anime" || normalized === "all" ? normalized : fallback; } function filterCompositionsByRuntime( comps: SurfacedComposition[], runtime: KeyframesCommandOptions["defaultRuntime"], ): SurfacedComposition[] { return comps.map((cmp) => ({ ...cmp, tweens: runtime === "gsap" || runtime === "all" ? cmp.tweens : [], traces: runtime === "gsap" || runtime === "all" ? cmp.traces : [], cssKeyframes: runtime === "css" || runtime === "all" ? cmp.cssKeyframes : [], anime: runtime === "anime" || runtime === "all" ? cmp.anime : [], })); } export default createKeyframesCommand();