Files
hyperframes/packages/cli/src/commands/keyframes.ts
T
Miguel Ángel b231b6f963 fix(cli): surface keyframes from scripts and styles inside <template> (#2374)
Sub-compositions are required to wrap markup, script, and style in <template>, but template content is an inert DocumentFragment that document-level querySelectorAll does not traverse — so 'hyperframes keyframes' silently surfaced zero tweens and zero CSS keyframes for every spec-conformant sub-composition. Extract from the document and every template content fragment (nested templates included, walked iteratively). Documents the extraction ordering contract and the deliberately document-root-only sub-composition discovery scan; pins template, multi-template, nested-template, and mixed-script cases with tests.
2026-07-13 20:47:50 -04:00

1023 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (0100). */
pct: number;
/** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */
time: number;
properties: Record<string, number | string>;
}
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<number | string>;
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 ──────────────────────────────────────────────────────────
// <template> content lives in an inert DocumentFragment that document-level
// querySelectorAll does not traverse — and sub-compositions are REQUIRED to wrap
// markup + script in <template>. Query the document and every template fragment
// (including templates nested inside another template's fragment, walked
// iteratively), or template-wrapped compositions surface zero tweens/keyframes.
// Ordering contract: document-level matches come first, then template contents
// in discovery order — NOT strict source order when a file mixes top-level and
// template scripts. Spec-conformant sub-compositions keep everything in one
// template, so mixed files only need to parse, not preserve interleaving.
function queryIncludingTemplates(html: string, selector: string): Element[] {
const doc = new DOMParser().parseFromString(html, "text/html");
const roots: Array<{ querySelectorAll(s: string): Iterable<Element> }> = [doc];
const queue = Array.from(doc.querySelectorAll("template")) as HTMLTemplateElement[];
while (queue.length > 0) {
const content = queue.shift()!.content;
if (!content) continue;
roots.push(content);
queue.push(...(Array.from(content.querySelectorAll("template")) as HTMLTemplateElement[]));
}
return roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
}
function inlineScriptText(html: string): string {
return queryIncludingTemplates(html, "script")
.filter((s) => !s.getAttribute("src"))
.map((s) => s.textContent ?? "")
.join("\n");
}
function inlineStyleText(html: string): string {
return queryIncludingTemplates(html, "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<string, number | string> | 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<string, number | string>): Record<string, number | string> {
const base: Record<string, number | string> = {};
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<string, number | string>): Record<string, number | string> {
const out: Record<string, number | string> = {};
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<string, number | string> }>;
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<string>, out: Set<string>): 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<string>, out: Set<string>): 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>): string[] {
const out = new Set<string>();
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<string, Set<string>>();
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<string>())],
keyframes: kf.stops,
}));
}
function valuesFromProperty(
script: string,
property: "targets" | "duration",
): Array<string | number> {
const values: Array<string | number> = [];
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<string, { min: number; max: number }>();
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<string, { min: number; max: number }>, 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<string, SurfacedTween[]>();
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)),
];
// Deliberately document-root only (NOT queryIncludingTemplates): host divs
// with [data-composition-src] live in the orchestrating index.html light
// tree, never inside <template>. Widening this scan would change discovery
// semantics, not fix a gap.
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, number | string>): 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<string>, cmp: SurfacedComposition): void {
for (const tr of cmp.traces) selectors.add(tr.target);
}
function addTweenSelectors(selectors: Set<string>, cmp: SurfacedComposition): void {
for (const t of cmp.tweens) {
if (t.method !== "set") selectors.add(t.target);
}
}
function addCssKeyframeSelectors(selectors: Set<string>, cmp: SurfacedComposition): void {
for (const css of cmp.cssKeyframes) {
for (const selector of css.selectors) {
if (selector) selectors.add(selector);
}
}
}
function addAnimeSelectors(selectors: Set<string>, 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<string>();
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<boolean> {
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<KeyframesCommandOptions> = {}) {
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 <canvas>).",
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();