|
| 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