feat(cli): keyframes command (surface GSAP/CSS/Anime keyframes + 3D onion-skin --shot) (#1603)

Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`,
renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data
model name where still accurate), and renames the shipped skill from
hyperframes-keyframes to hyperframes-motion. Expands the skill from a command
reference into a full motion-design workflow: reading motion, 3D angle verification,
layered GSAP motion, one-shot reference reproduction, diagnostic checks, and
eval-derived craft guidance.
This commit is contained in:
Miguel Ángel
2026-07-01 19:51:55 -07:00
committed by GitHub
parent 145c71e837
commit a908af11a8
34 changed files with 3973 additions and 159 deletions
@@ -0,0 +1,245 @@
// Pure, Node-side geometry + SVG generation for the onion-skin motion shot.
//
// The headless step (motionShot.ts) only SAMPLES — it seeks the live timeline
// and reads each element's projected corners. Everything else (which times to
// sample, how to fit/lay them out, and the SVG markup) lives here as pure
// functions so it can be unit-tested without a browser.
export interface Pt {
x: number;
y: number;
}
/** One time-sample of one element: its 4 projected corners, centre, colour, opacity. */
export interface OnionSample {
t: number;
q: Pt[];
c: Pt;
color: string;
opacity: number;
}
export interface OnionElement {
selector: string;
samples: OnionSample[];
}
export type ShotLayout = "path" | "strip";
export interface ShotLayoutOptions {
layout: ShotLayout;
fit: boolean;
width: number;
height: number;
/** Caption drawn top-left (camera / framing / window info). */
label?: string;
}
export interface Camera {
yaw: number;
pitch: number;
}
const ANGLE_PRESETS: Record<string, [number, number]> = {
front: [0, 0],
iso: [30, -22],
top: [0, -68],
side: [78, 0],
"rear-iso": [205, -22],
};
/** Parse an angle preset name or "yaw,pitch" degrees into a Camera. */
export function parseAngle(a?: string): Camera {
if (!a) return { yaw: 0, pitch: 0 };
const preset = ANGLE_PRESETS[a];
if (preset) return { yaw: preset[0], pitch: preset[1] };
const [y, p] = a.split(",").map((n) => Number.parseFloat(n));
return { yaw: Number.isFinite(y) ? y! : 0, pitch: Number.isFinite(p) ? p! : 0 };
}
/** Resolve which animated selectors a `--shot --selector SCOPE` should sample.
*
* The scope element is often a STATIC wrapper (the standard `.clip` root) whose
* animated CHILDREN carry the tweens — so a literal match against animated
* targets finds nothing. We fall back to the animated descendants of the scope:
*
* 1. scope itself is animated → sample just scope (exact selection)
* 2. scope is static but has animated → sample those descendants
* descendants (e.g. `.clip` wrapper)
* 3. scope contains nothing animated → sample [] (caller errors, naming
* the nearest animated elements)
*
* `isDescendant(scope, target)` is supplied by the caller (DOM-aware in the
* browser); kept as a param so this decision is pure and unit-testable.
*/
export function resolveShotSelectors(
scope: string,
animated: string[],
isDescendant: (scope: string, target: string) => boolean,
): string[] {
if (animated.includes(scope)) return [scope];
return animated.filter((target) => isDescendant(scope, target));
}
/** N equal-time sample points across [from?, to?] within [0, dur]. */
export function sampleTimes(
dur: number,
n: number,
from: number | null,
to: number | null,
): number[] {
const t0 = from != null ? Math.max(0, Math.min(from, dur)) : 0;
const t1 = to != null ? Math.max(0, Math.min(to, dur)) : dur;
const count = Math.max(1, Math.floor(n));
if (count === 1) return [t0];
return Array.from({ length: count }, (_, i) => {
const t = t0 + (i / (count - 1)) * (t1 - t0);
return Math.round(t * 1000) / 1000;
});
}
/** Opacity ramp for the rendered ("ghost") onion-skin: older frames fainter,
* the newest frame solid, so the composite of real painted frames reads as a
* motion trail leading to the final pose. One alpha in [0,1] per sample. */
export function ghostAlphas(n: number): number[] {
if (n <= 0) return [];
if (n === 1) return [1];
const lo = 0.14;
return Array.from(
{ length: n },
(_, i) => Math.round((lo + (1 - lo) * (i / (n - 1))) * 1000) / 1000,
);
}
/** Scale+centre transform that fits `pts` into a W×H frame (with padding). */
export function fitTransform(
pts: Pt[],
width: number,
height: number,
): { k: number; cx: number; cy: number } {
if (pts.length === 0) return { k: 1, cx: width / 2, cy: height / 2 };
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const span = Math.max(maxX - minX, maxY - minY, 1);
const k = Math.max(0.3, Math.min(7, (Math.min(width, height) * 0.8) / span));
return { k, cx, cy };
}
/** Grid geometry for the filmstrip layout. */
export function stripCells(n: number, width: number, height: number) {
const cols = n <= 5 ? Math.max(1, n) : Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
return { cols, rows, cellW: width / cols, cellH: height / rows };
}
const timeColor = (f: number) => `hsl(${190 + f * 150} 90% 65%)`;
const attrs = (o: Record<string, string | number>) =>
Object.entries(o)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
const polygon = (corners: Pt[], fill: string, fillOpacity: number, stroke: string) =>
`<polygon ${attrs({
points: corners.map((p) => `${round(p.x)},${round(p.y)}`).join(" "),
fill,
"fill-opacity": fillOpacity.toFixed(2),
stroke,
"stroke-width": 2.5,
"stroke-linejoin": "round",
})}/>`;
const line = (a: Pt, b: Pt, stroke: string, w: number, o: number) =>
`<line ${attrs({ x1: round(a.x), y1: round(a.y), x2: round(b.x), y2: round(b.y), stroke, "stroke-width": w, opacity: o, "stroke-linecap": "round" })}/>`;
const circle = (p: Pt, r: number, fill: string) =>
`<circle ${attrs({ cx: round(p.x), cy: round(p.y), r, fill })}/>`;
const text = (p: Pt, s: string, fill: string, size = 15) =>
`<text ${attrs({ x: round(p.x), y: round(p.y), fill, "font-family": "ui-monospace,monospace", "font-size": size, "font-weight": 600 })}>${escapeXml(s)}</text>`;
const round = (n: number) => Math.round(n * 100) / 100;
const escapeXml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ghost = (corners: Pt[], center: Pt, color: string, opacity: number, f: number): string => {
const tickEnd = {
x: (corners[0]!.x + corners[1]!.x) / 2,
y: (corners[0]!.y + corners[1]!.y) / 2,
};
return (
polygon(corners, color, Math.max(0.08, opacity * 0.42), timeColor(f)) +
line(center, tickEnd, timeColor(f), 3, 0.9)
);
};
/** Build the full onion-skin SVG overlay markup from sampled elements. */
export function buildOnionSvg(elements: OnionElement[], opt: ShotLayoutOptions): string {
const { width: W, height: H } = opt;
let body = "";
if (opt.layout === "strip") {
body = stripBody(elements[0]?.samples ?? [], W, H);
} else {
body = pathBody(elements, opt.fit, W, H);
}
if (opt.label) body += text({ x: 28, y: 40 }, opt.label, timeColor(0), 18);
return `<svg ${attrs({
xmlns: "http://www.w3.org/2000/svg",
style: "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647",
viewBox: `0 0 ${W} ${H}`,
})}>${body}</svg>`;
}
function pathBody(elements: OnionElement[], fit: boolean, W: number, H: number): string {
const all = elements.flatMap((e) => e.samples.flatMap((s) => [...s.q, s.c]));
const { k, cx, cy } = fit ? fitTransform(all, W, H) : { k: 1, cx: W / 2, cy: H / 2 };
const M = (p: Pt): Pt => ({ x: (p.x - cx) * k + W / 2, y: (p.y - cy) * k + H / 2 });
let out = "";
for (const el of elements) {
const last = el.samples.length - 1;
const fOf = (i: number) => (last <= 0 ? 0 : i / last);
el.samples.forEach((s, i) => (out += ghost(s.q.map(M), M(s.c), s.color, s.opacity, fOf(i))));
for (let i = 0; i < last; i++)
out += line(M(el.samples[i]!.c), M(el.samples[i + 1]!.c), timeColor(fOf(i)), 3.5, 0.85);
el.samples.forEach((s, i) => {
const c = M(s.c);
out += circle(c, 4, timeColor(fOf(i)));
out += text({ x: c.x + 10, y: c.y + (i % 2 === 0 ? -10 : 18) }, `${s.t}s`, timeColor(fOf(i)));
});
}
return out;
}
function stripBody(samples: OnionSample[], W: number, H: number): string {
if (samples.length === 0) return "";
const { cols, cellW, cellH } = stripCells(samples.length, W, H);
let maxExt = 1;
for (const s of samples)
for (const p of s.q) maxExt = Math.max(maxExt, Math.hypot(p.x - s.c.x, p.y - s.c.y));
const cellScale = (Math.min(cellW, cellH) * 0.62) / maxExt;
const last = samples.length - 1;
let out = "";
samples.forEach((s, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const cc = { x: cellW * (col + 0.5), y: cellH * (row + 0.5) };
const f = last <= 0 ? 0 : i / last;
out += `<rect ${attrs({ x: round(col * cellW + 3), y: round(row * cellH + 3), width: round(cellW - 6), height: round(cellH - 6), fill: "none", stroke: "#1c2531", "stroke-width": 1, rx: 8 })}/>`;
const corners = s.q.map((p) => ({
x: cc.x + (p.x - s.c.x) * cellScale,
y: cc.y + (p.y - s.c.y) * cellScale,
}));
out += ghost(corners, cc, s.color, s.opacity, f);
out += text({ x: col * cellW + 12, y: row * cellH + 24 }, `${s.t}s`, timeColor(f), 16);
});
return out;
}