Skip to content

Commit b5e2a3d

Browse files
committed
feat(cli): add keyframes command to surface motion paths
Read-only ASCII + --json view of every GSAP tween's keyframes and motion path. Ships the hyperframes-keyframes agent skill.
1 parent 1ad5aa6 commit b5e2a3d

3 files changed

Lines changed: 459 additions & 0 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const commandLoaders = {
120120
lint: () => import("./commands/lint.js").then((m) => m.default),
121121
beats: () => import("./commands/beats.js").then((m) => m.default),
122122
inspect: () => import("./commands/inspect.js").then((m) => m.default),
123+
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
123124
layout: () => import("./commands/layout.js").then((m) => m.default),
124125
info: () => import("./commands/info.js").then((m) => m.default),
125126
compositions: () => import("./commands/compositions.js").then((m) => m.default),
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
import { defineCommand } from "citty";
2+
import { existsSync, readFileSync, statSync } from "node:fs";
3+
import { resolve, dirname, basename } from "node:path";
4+
import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
5+
import type { Example } from "./_examples.js";
6+
import { c } from "../ui/colors.js";
7+
import { ensureDOMParser } from "../utils/dom.js";
8+
import { resolveProject } from "../utils/project.js";
9+
import { withMeta } from "../utils/updateCheck.js";
10+
11+
export const examples: Example[] = [
12+
["Surface every keyframe + motion path in the project", "hyperframes keyframes"],
13+
["Inspect one composition file", "hyperframes keyframes compositions/scene.html"],
14+
["Machine-readable output for an agent", "hyperframes keyframes --json"],
15+
["Only one element's tweens", "hyperframes keyframes --selector '#puck-a'"],
16+
];
17+
18+
// ── Surfaced shapes ──────────────────────────────────────────────────────────
19+
20+
interface KeyframePoint {
21+
/** Tween-relative percentage (0–100). */
22+
pct: number;
23+
/** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */
24+
time: number;
25+
properties: Record<string, number | string>;
26+
}
27+
28+
interface SurfacedTween {
29+
id: string;
30+
target: string;
31+
method: string;
32+
group?: string;
33+
start: number;
34+
duration: number;
35+
end: number;
36+
/** "keyframes" (array/object form), "flat" (to/from), or "motionPath". */
37+
shape: "keyframes" | "flat" | "motionPath";
38+
keyframes: KeyframePoint[];
39+
/** x/y position points (gsap offsets) when this tween animates position. */
40+
path: Array<{ x: number; y: number }> | null;
41+
}
42+
43+
interface SurfacedComposition {
44+
composition: string;
45+
source: string;
46+
tweens: SurfacedTween[];
47+
}
48+
49+
// ── GSAP extraction ──────────────────────────────────────────────────────────
50+
51+
function inlineScriptText(html: string): string {
52+
const doc = new DOMParser().parseFromString(html, "text/html");
53+
return Array.from(doc.querySelectorAll("script"))
54+
.filter((s) => !s.getAttribute("src"))
55+
.map((s) => s.textContent ?? "")
56+
.join("\n");
57+
}
58+
59+
function num(v: number | string | undefined): number | null {
60+
if (typeof v === "number") return v;
61+
if (typeof v === "string") {
62+
const n = Number.parseFloat(v);
63+
return Number.isFinite(n) ? n : null;
64+
}
65+
return null;
66+
}
67+
68+
function isPositionTween(anim: GsapAnimation): boolean {
69+
if (anim.propertyGroup === "position") return true;
70+
const has = (p: Record<string, number | string> | undefined) => !!p && ("x" in p || "y" in p);
71+
if (has(anim.properties) || has(anim.fromProperties)) return true;
72+
return (anim.keyframes?.keyframes ?? []).some(
73+
(kf) => "x" in kf.properties || "y" in kf.properties,
74+
);
75+
}
76+
77+
// The rest-state value for an animated property (what GSAP animates to/from when
78+
// the other endpoint is the element's natural pose): 1 for scale/opacity, 0 for
79+
// translate/rotation.
80+
function baseProps(props: Record<string, number | string>): Record<string, number | string> {
81+
const base: Record<string, number | string> = {};
82+
for (const k of Object.keys(props)) {
83+
if (k === "ease") continue;
84+
base[k] = k === "opacity" || k.startsWith("scale") ? 1 : 0;
85+
}
86+
return base;
87+
}
88+
89+
// Flat tweens carry no explicit keyframes — synthesize a 0%/100% pair against the
90+
// element's rest pose so the surface (and ASCII path) is uniform. `from()` goes
91+
// fromProperties → base; `to()` goes base → properties.
92+
function flatKeyframes(anim: GsapAnimation): KeyframePoint[] {
93+
if (anim.method === "fromTo") {
94+
return [
95+
{ pct: 0, time: 0, properties: anim.fromProperties ?? {} },
96+
{ pct: 100, time: 0, properties: anim.properties ?? {} },
97+
];
98+
}
99+
// to()/from() vars both live in anim.properties; from() plays them in reverse
100+
// against the element's rest pose.
101+
const vars = anim.properties ?? {};
102+
const base = baseProps(vars);
103+
return anim.method === "from"
104+
? [
105+
{ pct: 0, time: 0, properties: vars },
106+
{ pct: 100, time: 0, properties: base },
107+
]
108+
: [
109+
{ pct: 0, time: 0, properties: base },
110+
{ pct: 100, time: 0, properties: vars },
111+
];
112+
}
113+
114+
// Studio-internal markers that aren't user motion: the position-hold `set` GSAP
115+
// runs before a keyframed position tween (`data: "hf-hold"`).
116+
function isHoldMarker(anim: GsapAnimation): boolean {
117+
return anim.properties?.data === "hf-hold" || anim.fromProperties?.data === "hf-hold";
118+
}
119+
120+
// Drop internal / non-visual keys so they don't pollute the surfaced keyframes.
121+
function cleanProps(props: Record<string, number | string>): Record<string, number | string> {
122+
const out: Record<string, number | string> = {};
123+
for (const [k, v] of Object.entries(props)) {
124+
if (k === "data" || k === "ease") continue;
125+
out[k] = v;
126+
}
127+
return out;
128+
}
129+
130+
function surfaceTween(anim: GsapAnimation): SurfacedTween {
131+
const start =
132+
typeof anim.resolvedStart === "number" ? anim.resolvedStart : (num(anim.position) ?? 0);
133+
const duration = anim.duration ?? 0;
134+
135+
let shape: SurfacedTween["shape"];
136+
let rawKfs: Array<{ percentage: number; properties: Record<string, number | string> }>;
137+
if (anim.keyframes?.keyframes?.length) {
138+
shape = "keyframes";
139+
rawKfs = anim.keyframes.keyframes;
140+
} else if (anim.arcPath?.enabled) {
141+
shape = "motionPath";
142+
rawKfs = [];
143+
} else {
144+
shape = "flat";
145+
rawKfs = flatKeyframes(anim).map((k) => ({ percentage: k.pct, properties: k.properties }));
146+
}
147+
148+
const keyframes: KeyframePoint[] = rawKfs.map((kf) => ({
149+
pct: kf.percentage,
150+
time: Math.round((start + (kf.percentage / 100) * duration) * 1000) / 1000,
151+
properties: cleanProps(kf.properties),
152+
}));
153+
154+
// Carry x/y forward across keyframes that only set one axis, so the path is
155+
// continuous (GSAP holds the last value for an unspecified property).
156+
let path: Array<{ x: number; y: number }> | null = null;
157+
if (isPositionTween(anim) && keyframes.length > 0) {
158+
let lastX = 0;
159+
let lastY = 0;
160+
path = keyframes.map((kf) => {
161+
const x = num(kf.properties.x);
162+
const y = num(kf.properties.y);
163+
if (x !== null) lastX = x;
164+
if (y !== null) lastY = y;
165+
return { x: lastX, y: lastY };
166+
});
167+
}
168+
169+
return {
170+
id: anim.id,
171+
target: anim.targetSelector,
172+
method: anim.method,
173+
group: anim.propertyGroup,
174+
start: Math.round(start * 1000) / 1000,
175+
duration,
176+
end: Math.round((start + duration) * 1000) / 1000,
177+
shape,
178+
keyframes,
179+
path,
180+
};
181+
}
182+
183+
// ── ASCII motion path ────────────────────────────────────────────────────────
184+
185+
/** Plot position points into a compact grid so an agent can SEE the motion
186+
* shape. Each keyframe is marked with its index (0–9, then a–z); the path is
187+
* traced with light dots. Coordinates are GSAP x/y offsets (px). */
188+
function asciiPath(points: Array<{ x: number; y: number }>, width = 48, height = 11): string[] {
189+
if (points.length === 0) return [];
190+
const xs = points.map((p) => p.x);
191+
const ys = points.map((p) => p.y);
192+
let minX = Math.min(...xs);
193+
let maxX = Math.max(...xs);
194+
let minY = Math.min(...ys);
195+
let maxY = Math.max(...ys);
196+
if (maxX - minX < 1) {
197+
minX -= 1;
198+
maxX += 1;
199+
}
200+
if (maxY - minY < 1) {
201+
minY -= 1;
202+
maxY += 1;
203+
}
204+
const cols = width;
205+
const rows = height;
206+
const toCol = (x: number) => Math.round(((x - minX) / (maxX - minX)) * (cols - 1));
207+
// Screen y grows downward — invert so up on screen = smaller gsap y.
208+
const toRow = (y: number) => Math.round(((y - minY) / (maxY - minY)) * (rows - 1));
209+
210+
const grid: string[][] = Array.from({ length: rows }, () =>
211+
Array.from({ length: cols }, () => " "),
212+
);
213+
// Sparse paths (≤36 pts) index each keyframe (0–9, a–z) so an agent can map a
214+
// mark to a keyframe to edit. Dense paths (gestures) only mark Start/End — the
215+
// shape is the signal; per-point exact values live in the keyframe list / JSON.
216+
const dense = points.length > 36;
217+
const mark = (i: number) => {
218+
if (dense) return i === 0 ? "S" : i === points.length - 1 ? "E" : "·";
219+
return i < 10 ? String(i) : String.fromCharCode(97 + (i - 10));
220+
};
221+
222+
// Trace segments with dots first, then overwrite endpoints with index marks.
223+
for (let i = 0; i < points.length - 1; i++) {
224+
const c0 = toCol(points[i]!.x);
225+
const r0 = toRow(points[i]!.y);
226+
const c1 = toCol(points[i + 1]!.x);
227+
const r1 = toRow(points[i + 1]!.y);
228+
const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1);
229+
for (let s = 1; s < steps; s++) {
230+
const cc = Math.round(c0 + ((c1 - c0) * s) / steps);
231+
const rr = Math.round(r0 + ((r1 - r0) * s) / steps);
232+
if (grid[rr]![cc] === " ") grid[rr]![cc] = "·";
233+
}
234+
}
235+
points.forEach((p, i) => {
236+
grid[toRow(p.y)]![toCol(p.x)] = mark(i);
237+
});
238+
239+
const top = ` ┌${"─".repeat(cols)}┐`;
240+
const body = grid.map((row) => ` │${row.join("")}│`);
241+
const bottom = ` └${"─".repeat(cols)}┘`;
242+
const legend = dense ? "S→E, · path" : "marks 0..n = keyframe order";
243+
const axis = ` x ${Math.round(minX)}..${Math.round(maxX)} y ${Math.round(minY)}..${Math.round(maxY)} (gsap px; ${legend})`;
244+
return [top, ...body, bottom, c.dim(axis)];
245+
}
246+
247+
// ── Composition surfacing ────────────────────────────────────────────────────
248+
249+
function surfaceComposition(html: string, label: string, source: string): SurfacedComposition {
250+
const script = inlineScriptText(html);
251+
let animations: GsapAnimation[] = [];
252+
try {
253+
animations = parseGsapScript(script).animations;
254+
} catch {
255+
animations = [];
256+
}
257+
return {
258+
composition: label,
259+
source,
260+
tweens: animations.filter((a) => !isHoldMarker(a)).map(surfaceTween),
261+
};
262+
}
263+
264+
function collectCompositions(indexPath: string): SurfacedComposition[] {
265+
const html = readFileSync(indexPath, "utf-8");
266+
const baseDir = dirname(indexPath);
267+
const out: SurfacedComposition[] = [
268+
surfaceComposition(html, basename(indexPath), basename(indexPath)),
269+
];
270+
271+
const doc = new DOMParser().parseFromString(html, "text/html");
272+
for (const div of Array.from(doc.querySelectorAll("[data-composition-src]"))) {
273+
const src = div.getAttribute("data-composition-src");
274+
if (!src) continue;
275+
const subPath = resolve(baseDir, src);
276+
if (!existsSync(subPath)) continue;
277+
const id = div.getAttribute("data-composition-id") ?? src;
278+
out.push(surfaceComposition(readFileSync(subPath, "utf-8"), id, src));
279+
}
280+
return out;
281+
}
282+
283+
// ── Render (human) ───────────────────────────────────────────────────────────
284+
285+
// Plot the ASCII grid only for genuine motion paths — multi-keyframe, or a path
286+
// that moves on BOTH axes. Simple 2-point single-axis slides (entrances, a flat
287+
// `to(x)`) are clear enough from the keyframe line alone.
288+
function shouldPlotPath(path: Array<{ x: number; y: number }>): boolean {
289+
const xs = path.map((p) => p.x);
290+
const ys = path.map((p) => p.y);
291+
const xVaries = Math.max(...xs) - Math.min(...xs) > 0.5;
292+
const yVaries = Math.max(...ys) - Math.min(...ys) > 0.5;
293+
const distinct = new Set(path.map((p) => `${p.x},${p.y}`)).size;
294+
return distinct > 2 || (xVaries && yVaries);
295+
}
296+
297+
function fmtProps(props: Record<string, number | string>): string {
298+
return Object.entries(props)
299+
.filter(([k]) => k !== "ease")
300+
.map(([k, v]) => `${k}:${v}`)
301+
.join(" ");
302+
}
303+
304+
function printTween(t: SurfacedTween): void {
305+
const timing = c.dim(`@${t.start}s→${t.end}s (${t.duration}s)`);
306+
const group = t.group ? c.dim(` ${t.group}`) : "";
307+
console.log(` ${c.accent(t.target)}${group} ${c.dim(t.method)}/${t.shape} ${timing}`);
308+
if (t.shape === "motionPath") {
309+
console.log(c.dim(` motionPath arc (${t.keyframes.length} stops)`));
310+
} else {
311+
const kfLine = t.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" ");
312+
console.log(` ${c.dim(kfLine)}`);
313+
}
314+
if (t.path && shouldPlotPath(t.path)) {
315+
for (const line of asciiPath(t.path)) console.log(line);
316+
}
317+
console.log();
318+
}
319+
320+
// ── Command ──────────────────────────────────────────────────────────────────
321+
322+
export default defineCommand({
323+
meta: {
324+
name: "keyframes",
325+
description: "Surface every GSAP tween, keyframe, and motion path for agent-driven editing",
326+
},
327+
args: {
328+
target: {
329+
type: "positional",
330+
description: "Project dir or composition .html",
331+
required: false,
332+
},
333+
selector: { type: "string", description: "Only tweens matching this CSS selector" },
334+
json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false },
335+
},
336+
async run({ args }) {
337+
ensureDOMParser();
338+
339+
// Accept either a project directory or a single .html file.
340+
const raw = args.target?.trim();
341+
let comps: SurfacedComposition[];
342+
let projectName: string;
343+
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
344+
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)];
345+
projectName = basename(raw);
346+
} else {
347+
const project = resolveProject(raw);
348+
comps = collectCompositions(project.indexPath);
349+
projectName = project.name;
350+
}
351+
352+
if (args.selector) {
353+
const sel = args.selector;
354+
comps = comps
355+
.map((cmp) => ({
356+
...cmp,
357+
tweens: cmp.tweens.filter((t) => t.target.split(",").some((s) => s.trim() === sel)),
358+
}))
359+
.filter((cmp) => cmp.tweens.length > 0);
360+
}
361+
362+
if (args.json) {
363+
console.log(JSON.stringify(withMeta({ project: projectName, compositions: comps }), null, 2));
364+
return;
365+
}
366+
367+
const total = comps.reduce((n, cmp) => n + cmp.tweens.length, 0);
368+
if (total === 0) {
369+
console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no GSAP tweens found")}`);
370+
return;
371+
}
372+
console.log(
373+
`${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} tween${total === 1 ? "" : "s"}`)}`,
374+
);
375+
console.log();
376+
for (const cmp of comps) {
377+
if (cmp.tweens.length === 0) continue;
378+
console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`));
379+
for (const t of cmp.tweens) printTween(t);
380+
}
381+
console.log(
382+
c.dim("Tip: edit the keyframes: [...] / x/y values in source, then re-run to verify."),
383+
);
384+
},
385+
});

0 commit comments

Comments
 (0)