Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const commandLoaders = {
lint: () => import("./commands/lint.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
Expand Down
385 changes: 385 additions & 0 deletions packages/cli/src/commands/keyframes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
import { defineCommand } from "citty";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve, dirname, basename } from "node:path";
import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";

export const examples: Example[] = [
["Surface every keyframe + motion path in the project", "hyperframes keyframes"],
["Inspect one composition file", "hyperframes keyframes compositions/scene.html"],
["Machine-readable output for an agent", "hyperframes keyframes --json"],
["Only one element's tweens", "hyperframes keyframes --selector '#puck-a'"],
];

// ── Surfaced shapes ──────────────────────────────────────────────────────────

interface KeyframePoint {
/** Tween-relative percentage (0–100). */
pct: number;
/** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */
time: number;
properties: Record<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;
}

interface SurfacedComposition {
composition: string;
source: string;
tweens: SurfacedTween[];
}

// ── GSAP extraction ──────────────────────────────────────────────────────────

function inlineScriptText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("script"))
.filter((s) => !s.getAttribute("src"))
.map((s) => s.textContent ?? "")
.join("\n");
}

function num(v: number | string | undefined): number | null {
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
}
return null;
}

function isPositionTween(anim: GsapAnimation): boolean {
if (anim.propertyGroup === "position") return true;
const has = (p: Record<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 surface (and ASCII path) is 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),
}));

// 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).
let path: Array<{ x: number; y: number }> | null = null;
if (isPositionTween(anim) && keyframes.length > 0) {
let lastX = 0;
let lastY = 0;
path = 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 };
});
}

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,
};
}

// ── ASCII motion path ────────────────────────────────────────────────────────

/** Plot position points into a compact grid so an agent can SEE the motion
* shape. Each keyframe is marked with its index (0–9, then a–z); the path is
* traced with light dots. Coordinates are GSAP x/y offsets (px). */
function asciiPath(points: Array<{ x: number; y: number }>, width = 48, height = 11): string[] {
if (points.length === 0) return [];
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
let minX = Math.min(...xs);
let maxX = Math.max(...xs);
let minY = Math.min(...ys);
let maxY = Math.max(...ys);
if (maxX - minX < 1) {
minX -= 1;
maxX += 1;
}
if (maxY - minY < 1) {
minY -= 1;
maxY += 1;
}
const cols = width;
const rows = height;
const toCol = (x: number) => Math.round(((x - minX) / (maxX - minX)) * (cols - 1));
// Screen y grows downward — invert so up on screen = smaller gsap y.
const toRow = (y: number) => Math.round(((y - minY) / (maxY - minY)) * (rows - 1));

const grid: string[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => " "),
);
// Sparse paths (≤36 pts) index each keyframe (0–9, a–z) so an agent can map a
// mark to a keyframe to edit. Dense paths (gestures) only mark Start/End — the
// shape is the signal; per-point exact values live in the keyframe list / JSON.
const dense = points.length > 36;
const mark = (i: number) => {
if (dense) return i === 0 ? "S" : i === points.length - 1 ? "E" : "·";
return i < 10 ? String(i) : String.fromCharCode(97 + (i - 10));
};

// Trace segments with dots first, then overwrite endpoints with index marks.
for (let i = 0; i < points.length - 1; i++) {
const c0 = toCol(points[i]!.x);
const r0 = toRow(points[i]!.y);
const c1 = toCol(points[i + 1]!.x);
const r1 = toRow(points[i + 1]!.y);
const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1);
for (let s = 1; s < steps; s++) {
const cc = Math.round(c0 + ((c1 - c0) * s) / steps);
const rr = Math.round(r0 + ((r1 - r0) * s) / steps);
if (grid[rr]![cc] === " ") grid[rr]![cc] = "·";
}
}
points.forEach((p, i) => {
grid[toRow(p.y)]![toCol(p.x)] = mark(i);
});

const top = ` ┌${"─".repeat(cols)}┐`;
const body = grid.map((row) => ` │${row.join("")}│`);
const bottom = ` └${"─".repeat(cols)}┘`;
const legend = dense ? "S→E, · path" : "marks 0..n = keyframe order";
const axis = ` x ${Math.round(minX)}..${Math.round(maxX)} y ${Math.round(minY)}..${Math.round(maxY)} (gsap px; ${legend})`;
return [top, ...body, bottom, c.dim(axis)];
}

// ── Composition surfacing ────────────────────────────────────────────────────

function surfaceComposition(html: string, label: string, source: string): SurfacedComposition {
const script = inlineScriptText(html);
let animations: GsapAnimation[] = [];
try {
animations = parseGsapScript(script).animations;
} catch {
animations = [];
}
return {
composition: label,
source,
tweens: animations.filter((a) => !isHoldMarker(a)).map(surfaceTween),
};
}

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

// Plot the ASCII grid only for genuine motion paths — multi-keyframe, or a path
// that moves on BOTH axes. Simple 2-point single-axis slides (entrances, a flat
// `to(x)`) are clear enough from the keyframe line alone.
function shouldPlotPath(path: Array<{ x: number; y: number }>): boolean {
const xs = path.map((p) => p.x);
const ys = path.map((p) => p.y);
const xVaries = Math.max(...xs) - Math.min(...xs) > 0.5;
const yVaries = Math.max(...ys) - Math.min(...ys) > 0.5;
const distinct = new Set(path.map((p) => `${p.x},${p.y}`)).size;
return distinct > 2 || (xVaries && yVaries);
}

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.path && shouldPlotPath(t.path)) {
for (const line of asciiPath(t.path)) console.log(line);
}
console.log();
}

// ── Command ──────────────────────────────────────────────────────────────────

export default defineCommand({
meta: {
name: "keyframes",
description: "Surface every GSAP tween, keyframe, and motion path for agent-driven editing",
},
args: {
target: {
type: "positional",
description: "Project dir or composition .html",
required: false,
},
selector: { type: "string", description: "Only tweens matching this CSS selector" },
json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false },
},
async run({ args }) {
ensureDOMParser();

// Accept either a project directory or a single .html file.
const raw = args.target?.trim();
let comps: SurfacedComposition[];
let projectName: string;
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)];
projectName = basename(raw);
} else {
const project = resolveProject(raw);
comps = collectCompositions(project.indexPath);
projectName = project.name;
}

if (args.selector) {
const sel = args.selector;
comps = comps
.map((cmp) => ({
...cmp,
tweens: cmp.tweens.filter((t) => t.target.split(",").some((s) => s.trim() === sel)),
}))
.filter((cmp) => cmp.tweens.length > 0);
}

if (args.json) {
console.log(JSON.stringify(withMeta({ project: projectName, compositions: comps }), null, 2));
return;
}

const total = comps.reduce((n, cmp) => n + cmp.tweens.length, 0);
if (total === 0) {
console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no GSAP tweens found")}`);
return;
}
console.log(
`${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} tween${total === 1 ? "" : "s"}`)}`,
);
console.log();
for (const cmp of comps) {
if (cmp.tweens.length === 0) continue;
console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`));
for (const t of cmp.tweens) printTween(t);
}
console.log(
c.dim("Tip: edit the keyframes: [...] / x/y values in source, then re-run to verify."),
);
},
});
Loading
Loading