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
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { orientation, durationFromHtml } from "./info.js";
describe("orientation", () => {
it("is landscape when width > height", () => {
expect(orientation(1920, 1080)).toBe("landscape");
});
it("is portrait when height > width", () => {
expect(orientation(1080, 1920)).toBe("portrait");
});
it("is square when width === height", () => {
expect(orientation(1080, 1080)).toBe("square");
});
});
describe("durationFromHtml", () => {
it("reads data-duration from the root composition element", () => {
const html = `<div data-composition-id="comp" data-width="1920" data-height="1080" data-start="0" data-duration="6"></div>`;
expect(durationFromHtml(html, 5)).toBe(6);
});
it("reads data-duration regardless of attribute order", () => {
const html = `<div data-duration="8" data-composition-id="comp"></div>`;
expect(durationFromHtml(html, 5)).toBe(8);
});
it("falls back to the computed timeline duration when no data-duration", () => {
const html = `<div data-composition-id="comp"></div>`;
expect(durationFromHtml(html, 5)).toBe(5);
});
});
+23 -3
View File
@@ -14,6 +14,25 @@ import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
/** Derive orientation label from actual pixel dimensions. */
export function orientation(width: number, height: number): "landscape" | "portrait" | "square" {
if (width > height) return "landscape";
if (height > width) return "portrait";
return "square";
}
/**
* Duration of the composition: prefer the root element's data-duration,
* fall back to the computed timeline end.
*/
export function durationFromHtml(html: string, fallback: number): number {
const match =
html.match(/data-composition-id[^>]*data-duration=["']([\d.]+)["']/) ||
html.match(/data-duration=["']([\d.]+)["'][^>]*data-composition-id/);
const value = match?.[1] ? parseFloat(match[1]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function totalSize(dir: string): number {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -56,6 +75,7 @@ export default defineCommand({
const width = widthMatch?.[1] ? parseInt(widthMatch[1], 10) : fallback.width;
const height = heightMatch?.[1] ? parseInt(heightMatch[1], 10) : fallback.height;
const resolution = `${width}x${height}`;
const duration = durationFromHtml(html, maxEnd);
const size = totalSize(project.dir);
const typeCounts: Record<string, number> = {};
@@ -71,10 +91,10 @@ export default defineCommand({
JSON.stringify(
withMeta({
name: project.name,
resolution: parsed.resolution,
resolution: orientation(width, height),
width,
height,
duration: maxEnd,
duration,
elements: parsed.elements.length,
tracks: tracks.size,
types: typeCounts,
@@ -89,7 +109,7 @@ export default defineCommand({
console.log(`${c.success("◇")} ${c.accent(project.name)}`);
console.log(label("Resolution", resolution));
console.log(label("Duration", `${maxEnd.toFixed(1)}s`));
console.log(label("Duration", `${duration.toFixed(1)}s`));
console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`));
console.log(label("Tracks", `${tracks.size}`));
console.log(label("Size", formatBytes(size)));
+143
View File
@@ -0,0 +1,143 @@
import { beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { collectShotSelectors, surfaceComposition } from "./keyframes.js";
beforeAll(() => ensureDOMParser());
const wrap = (script: string) =>
`<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="dot" class="clip"></div></div><script>${script}</script></body></html>`;
describe("keyframes multi-stroke traces", () => {
it("composites ≥2 position strokes on one element into a single trace", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: -100, y: -150 }, "100%": { x: 80, y: -120 } }, duration: 1 });
tl.to("#dot", { keyframes: { "0%": { x: 80, y: 120 }, "100%": { x: 85, y: 140 } }, duration: 1 });
window.__timelines = [tl];
`);
const { traces } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(1);
expect(traces[0]!.target).toBe("#dot");
expect(traces[0]!.strokes).toHaveLength(2);
});
it("treats a 0-duration set() between strokes as a pen-up jump, not a drawn stroke", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 0 } }, duration: 1 });
tl.set("#dot", { x: 200, y: 200 });
tl.to("#dot", { keyframes: { "0%": { x: 200, y: 200 }, "100%": { x: 250, y: 250 } }, duration: 1 });
window.__timelines = [tl];
`);
const { traces } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(1);
// two DRAWN strokes; the set() is the pen-up gap and is excluded
expect(traces[0]!.strokes).toHaveLength(2);
});
it("leaves a single-stroke element untraced (normal per-tween output)", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 200, y: -100 }, "100%": { x: 0, y: 0 } }, duration: 3 });
window.__timelines = [tl];
`);
const { traces, tweens } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(0);
expect(tweens.length).toBeGreaterThan(0);
});
});
describe("keyframes composed-ancestor surfacing (nested elements)", () => {
const nested = (script: string) =>
`<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="stage"><div id="hero"><div id="core" class="clip"></div></div></div></div><script>${script}</script></body></html>`;
it("annotates a child tween with its animated ANCESTOR's motion", () => {
const html = nested(`
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: { "0%": { x: -300, y: 0 }, "100%": { x: 300, y: 0 } }, duration: 4 }, 0);
tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0);
window.__timelines = [tl];
`);
const { tweens } = surfaceComposition(html, "index.html", "index.html");
const core = tweens.find((t) => t.target === "#core");
expect(core?.composedWith?.map((a) => a.selector)).toContain("#hero");
// and the ancestor's path EXTENT is summarised (range, not endpoints — so a
// closed loop still reveals its travel)
expect(core?.composedWith?.[0]!.summary).toMatch(/x -300\.\.300/);
});
it("does not annotate when the parent isn't animated", () => {
const html = nested(`
const tl = gsap.timeline({ paused: true });
tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0);
window.__timelines = [tl];
`);
const { tweens } = surfaceComposition(html, "index.html", "index.html");
expect(tweens.find((t) => t.target === "#core")?.composedWith).toBeUndefined();
});
});
describe("keyframes runtime surfacing", () => {
it("surfaces CSS @keyframes and their animated selectors", () => {
const html = `<!doctype html><html><head><style>
.dot { animation: rise 1200ms ease-out both; }
@keyframes rise {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}
</style></head><body><div class="dot"></div></body></html>`;
const { cssKeyframes } = surfaceComposition(html, "index.html", "index.html");
expect(cssKeyframes).toHaveLength(1);
expect(cssKeyframes[0]!.name).toBe("rise");
expect(cssKeyframes[0]!.selectors).toContain(".dot");
expect(cssKeyframes[0]!.keyframes.map((kf) => kf.selector)).toEqual(["0%", "100%"]);
});
it("does not let a CSS comment before @keyframes leak into the next rule's selector", () => {
const html = `<!doctype html><html><head><style>
/* Grain animation */
@keyframes rise { 0% { opacity: 0; } 100% { opacity: 1; } }
.dot { animation: rise 1s both; }
</style></head><body><div class="dot"></div></body></html>`;
const { cssKeyframes } = surfaceComposition(html, "index.html", "index.html");
expect(cssKeyframes[0]!.selectors).toEqual([".dot"]);
});
it("surfaces Anime.js calls and explicit HyperFrames registration", () => {
const html = wrap(`
const tl = anime.createTimeline({ autoplay: false });
tl.add(".chip", { translateX: [0, 240], duration: 900 });
window.__hfAnime = window.__hfAnime || [];
window.__hfAnime.push(tl);
`);
const { anime } = surfaceComposition(html, "index.html", "index.html");
expect(anime).toHaveLength(1);
expect(anime[0]!.kind).toBe("timeline");
expect(anime[0]!.registered).toBe(true);
expect(anime[0]!.targets).toContain(".chip");
expect(anime[0]!.durations).toContain(900);
});
it("uses CSS and Anime targets as onion-shot candidates", () => {
const cssHtml = `<!doctype html><html><head><style>
.dot { animation: rise 1200ms ease-out both; }
@keyframes rise {
0% { transform: translateY(40px); }
100% { transform: translateY(0); }
}
</style></head><body><div class="dot"></div></body></html>`;
const animeHtml = wrap(`
const tl = anime.createTimeline({ autoplay: false });
tl.add(".chip", { translateX: [0, 240], duration: 900 });
window.__hfAnime = window.__hfAnime || [];
window.__hfAnime.push(tl);
`);
const selectors = collectShotSelectors([
surfaceComposition(cssHtml, "css.html", "css.html"),
surfaceComposition(animeHtml, "anime.html", "anime.html"),
]).map((item) => item.selector);
expect(selectors).toEqual(expect.arrayContaining([".dot", ".chip"]));
});
});
+983
View File
@@ -0,0 +1,983 @@
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 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 ──────────────────────────────────────────────────────────
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 inlineStyleText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("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)),
];
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,
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),
);
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.
function resolveScope(args: { target?: string; selector?: string }): {
comps: SurfacedComposition[];
allComps: SurfacedComposition[];
projectName: string;
projectDir: string | undefined;
} {
const raw = args.target?.trim();
let comps: SurfacedComposition[];
let projectName: string;
let projectDir: string | undefined;
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)];
projectName = basename(raw);
projectDir = dirname(raw);
} 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 };
}
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 } = 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, 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();
@@ -359,6 +359,17 @@
};
}
// An ancestor (up to and including `stopAt`) that clips its overflow makes any
// text spilling past it invisible — that clipping IS the layout mechanism
// (odometer/ticker reels, masked windows), not a defect to report.
function clippedByAncestor(element, stopAt) {
for (let current = element; current; current = current.parentElement) {
if (current !== element && clipsOverflow(getComputedStyle(current))) return true;
if (current === stopAt) break;
}
return false;
}
function textOverflowIssues(element, root, rootRect, time, tolerance) {
const textRect = textRectFor(element);
if (!textRect) return [];
@@ -382,7 +393,11 @@
? tolerance
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (containerOverflow && !hasAllowOverflowFlag(element)) {
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!clippedByAncestor(element, container)
) {
const style = elementStyle;
issues.push({
code: "text_box_overflow",
@@ -622,13 +637,31 @@
return Math.min(opacityChain(textScene), opacityChain(occluderScene)) < 0.999;
}
// The nearest ancestor establishing a 3D rendering context, or null. Elements
// sharing one are depth-sorted in 3D, so a "covering" hit is legitimate
// perspective (e.g. the back face of a preserve-3d cube), not a 2D overlap.
function preserve3dContext(element) {
for (let current = element; current; current = current.parentElement) {
const ts = getComputedStyle(current).transformStyle;
if (ts === "preserve-3d") return current;
}
return null;
}
function sharedPreserve3d(a, b) {
const ctx = preserve3dContext(a);
return !!ctx && ctx === preserve3dContext(b);
}
// The opaque element painted over (x, y), or null when the topmost element
// there is related to the text, non-opaque, or a transient crossfade overlap.
// there is related to the text, non-opaque, sharing a 3D context with it, or
// part of a transient crossfade overlap.
// fallow-ignore-next-line complexity
function occluderAt(element, x, y) {
if (typeof document.elementFromPoint !== "function") return null;
const hit = document.elementFromPoint(x, y);
if (!isForeignElement(element, hit)) return null;
if (sharedPreserve3d(element, hit)) return null;
if (!isOpaqueOccluder(hit)) return null;
if (isCrossSceneTransitionOverlap(element, hit)) return null;
return hit;
+663
View File
@@ -0,0 +1,663 @@
// Onion-skin motion screenshot: seek the LIVE timeline at N equal-time steps and
// project the REAL element at each step, so an agent can SELF-VERIFY motion (the
// rendered result — every channel: position, rotation, scale, opacity, colour),
// not just the authored x/y numbers. Reuses the headless-Chrome + static-server
// pattern from layout.ts.
//
// 3D is captured for free: zero-size marker children at the element's corners are
// projected by the browser, so a tilted/edge-on element renders as a real quad.
// Framing controls (samples / time window / fit / filmstrip) let the agent frame
// exactly what it's editing. All geometry + SVG live in ./motionShotLayout.ts
// (pure, tested); this file only drives the browser and SAMPLES.
import { writeFileSync } from "node:fs";
import {
buildOnionSvg,
ghostAlphas,
parseAngle,
resolveShotSelectors,
sampleTimes,
type OnionElement,
} from "./motionShotLayout.js";
export interface ShotRequest {
/** CSS selector of the moving element to sample (e.g. "#dot"). */
selector: string;
}
/** Returned by the in-browser selector resolver: which animated selectors a
* `--selector SCOPE` actually resolves to (scope itself, or its descendants),
* plus diagnostic context when nothing under the scope animates. */
interface ScopeResolution {
/** Animated selectors to sample (subset of `requests`). */
selectors: string[];
/** True when the scope selector matched a real element in the DOM. */
scopeExists: boolean;
}
export interface ShotOptions {
/** Equal-time samples across the (windowed) timeline. Default 9. */
samples?: number;
/** "path" = ghosts at real positions + path; "strip" = filmstrip by time. */
layout?: "path" | "strip";
/** Zoom the motion to fill the frame. Default true. */
fit?: boolean;
/** Sample only this time window (seconds) — dense inspection of one phase. */
from?: number | null;
to?: number | null;
/** Orbit camera: a preset (front|iso|top|side) or "yaw,pitch" degrees. */
angle?: string;
/** `--selector` scope: when the user focused one element, narrow `requests`
* to that element if it animates, else to its animated descendants (so a
* static `.clip` wrapper resolves to the animated children under it). */
scopeSelector?: string | null;
/** Rendered ("ghost") onion-skin: capture the canvas pixels at each sample and
* composite them as translucent ghosts (older fainter → newest solid) — the
* canvas/WebGL motion the bbox-marker onion can't see. Requires a <canvas>. */
ghost?: boolean;
}
interface PageSample {
t: number;
q: Array<{ x: number; y: number }>;
c: { x: number; y: number };
color: string;
opacity: number;
}
type OrbitCamera = { yaw: number; pitch: number };
type FrameSize = { width: number; height: number };
// Runs IN THE BROWSER (serialized by page.evaluate). Make the element's ancestor
// chain preserve-3d, strip intermediate perspective, put one perspective on the
// composition root's parent (the lens) and rotate the root — so the element's own
// 3D is viewed from the requested angle on any composition shape (no #stage assumption).
function applyOrbitCamera(selectors: string[], cam: OrbitCamera): void {
const first = document.querySelector(selectors[0] ?? "");
const root =
(first?.closest("[data-composition-id]") as HTMLElement | null) ??
(document.querySelector("#stage") as HTMLElement | null) ??
(document.body.firstElementChild as HTMLElement | null) ??
document.body;
for (const sel of selectors) {
let n = document.querySelector(sel) as HTMLElement | null;
while (n && n !== root) {
n.style.transformStyle = "preserve-3d";
n.style.perspective = "none";
n = n.parentElement;
}
}
root.style.transformStyle = "preserve-3d";
root.style.perspective = "none";
root.style.transformOrigin = "50% 50%";
root.style.transform = `rotateX(${cam.pitch}deg) rotateY(${cam.yaw}deg)`;
const lens = root.parentElement ?? document.body;
lens.style.perspective = "1600px";
lens.style.perspectiveOrigin = "50% 50%";
}
// Runs IN THE BROWSER. Composite N real painted frames (data URLs) onto a black
// canvas with per-frame opacity (older fainter → newest solid) so canvas/WebGL
// motion reads as a rendered onion-skin trail. Returns the composite as a PNG
// data URL. Used by the `--ghost` mode.
function compositeGhostFrames(
frames: string[],
alphas: number[],
W: number,
H: number,
label: string,
): Promise<string> {
return new Promise((resolve) => {
const cv = document.createElement("canvas");
cv.width = W;
cv.height = H;
const ctx = cv.getContext("2d");
if (!ctx) {
resolve("");
return;
}
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, W, H);
let i = 0;
const step = () => {
if (i >= frames.length) {
ctx.globalAlpha = 1;
ctx.font = "600 22px ui-monospace, SFMono-Regular, Menlo, monospace";
ctx.fillStyle = "#86c2ff";
ctx.fillText(label, 24, 38);
resolve(cv.toDataURL("image/png"));
return;
}
const img = new Image();
img.onload = () => {
ctx.globalAlpha = alphas[i] ?? 1;
ctx.drawImage(img, 0, 0, W, H);
i += 1;
step();
};
img.onerror = () => {
i += 1;
step();
};
img.src = frames[i] ?? "";
};
step();
});
}
// Runs IN THE BROWSER. Self-contained (only `tt` + window/document — never a
// Node-side closure variable), pauses/seeks every adapter to time `tt`: GSAP
// `__timelines`, the Web Animations API, `__hfAnime` instances, then dispatches
// `hf-seek` and nudges the three/GSAP render hooks. This one routine backs BOTH
// the ghost-frame capture and the marker sampler below — see installSeekHelper
// for why it's installed as a page global instead of being duplicated inline
// (Puppeteer's page.evaluate only serializes the single function passed to it,
// so a Node-side function can't be *called* from inside another evaluate
// callback; it can only be reused by installing its source as a real page
// global once, up front).
function seekAllAdaptersInBrowser(tt: number): void {
const tryCall = (fn: () => void): void => {
try {
fn();
} catch {
/* best-effort */
}
};
const w = window as unknown as {
__player?: { renderSeek?: (t: number) => void; seek?: (t: number) => void };
__hfThreeTime?: number;
__hfThreeRender?: () => void;
__hfAnime?: Array<{ pause?: () => void; seek?: (timeMs: number) => void }>;
gsap?: { ticker?: { tick?: () => void } };
__timelines?: Record<
string,
{
pause?: () => void;
seek?: (t: number) => void;
totalTime?: (t: number, s: boolean) => void;
}
>;
};
const timeMs = Math.max(0, tt * 1000);
tryCall(() => {
if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(tt);
else if (typeof w.__player?.seek === "function") w.__player.seek(tt);
});
Object.values(w.__timelines ?? {}).forEach((tl) => {
tryCall(() => {
tl.pause?.();
if (typeof tl.totalTime === "function") {
tl.totalTime(tt + 0.001, true);
tl.totalTime(tt, false);
} else {
tl.seek?.(tt);
}
});
});
tryCall(() => {
if (typeof document.getAnimations === "function") {
for (const animation of document.getAnimations()) {
tryCall(() => {
animation.currentTime = timeMs;
});
tryCall(() => animation.pause());
}
}
});
for (const instance of w.__hfAnime ?? []) {
tryCall(() => {
instance.pause?.();
instance.seek?.(timeMs);
});
}
tryCall(() => {
w.__hfThreeTime = tt;
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } }));
w.__hfThreeRender?.();
w.gsap?.ticker?.tick?.();
});
}
// Installs seekAllAdaptersInBrowser as a real `window` global, once per page
// load. Both the ghost-frame capture and the marker sampler then call it via a
// plain property access instead of re-declaring its body — see the function's
// own doc comment for why a page global (rather than a Node-side reference) is
// required here.
async function installSeekHelper(page: import("puppeteer-core").Page): Promise<void> {
await page.evaluate(`window.__hfSeekAllAdapters = ${seekAllAdaptersInBrowser.toString()};`);
}
// Launch headless Chrome, load the composition sized to its canvas, wait for the
// timelines + fonts to be ready. Returns the browser (caller closes it), page, size.
async function openCompositionPage(
url: string,
executablePath: string,
): Promise<{
browser: import("puppeteer-core").Browser;
page: import("puppeteer-core").Page;
size: FrameSize;
}> {
const puppeteer = await import("puppeteer-core");
const browser = await puppeteer.default.launch({
headless: true,
executablePath,
args: [
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--enable-webgl",
"--use-gl=angle",
"--use-angle=swiftshader",
],
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
const size = await page.evaluate(() => {
const root = document.querySelector("[data-composition-id][data-width][data-height]");
const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0;
const h = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0;
return {
width: Number.isFinite(w) && w > 0 ? Math.min(w, 4096) : 1920,
height: Number.isFinite(h) && h > 0 ? Math.min(h, 4096) : 1080,
};
});
await page.setViewport(size);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
timeout: 10000,
})
.catch(() => {});
await page
.evaluate(async () => {
const d = document as unknown as { fonts?: { ready?: Promise<unknown> } };
if (d.fonts?.ready) await d.fonts.ready;
})
.catch(() => {});
await installSeekHelper(page);
return { browser, page, size };
}
// Longest seekable duration (seconds) across registered timelines, player/root
// duration, CSS/WAAPI animations, and Anime.js instances.
function timelineDuration(page: import("puppeteer-core").Page): Promise<number> {
return page.evaluate(() => {
const finiteSeconds = (value: unknown): number => {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) && n > 0 ? n : 0;
};
const finiteMsToSeconds = (value: unknown): number => {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) && n > 0 ? n / 1000 : 0;
};
const w = window as unknown as {
__player?: { getDuration?: () => number };
__timelines?: Record<string, { duration?: () => number; totalDuration?: () => number }>;
__hfAnime?: Array<{ duration?: number | string; totalDuration?: number | string }>;
};
const timelinesDuration = (): number => {
let d = 0;
for (const tl of Object.values(w.__timelines ?? {})) {
try {
d = Math.max(d, (tl.totalDuration?.() ?? tl.duration?.() ?? 0) as number);
} catch {
// skip
}
}
return d;
};
const animationDurationSeconds = (animation: Animation): number => {
const timing = animation.effect?.getTiming?.();
if (!timing) return 0;
const durationMs = Number(timing.duration);
const iterations = Number(timing.iterations ?? 1);
if (!Number.isFinite(durationMs) || !Number.isFinite(iterations)) return 0;
const delayMs = Number(timing.delay ?? 0);
const endDelayMs = Number(timing.endDelay ?? 0);
return finiteMsToSeconds(Math.max(0, delayMs) + durationMs * iterations + endDelayMs);
};
const waapiDuration = (): number => {
if (typeof document.getAnimations !== "function") return 0;
let d = 0;
try {
for (const animation of document.getAnimations()) {
d = Math.max(d, animationDurationSeconds(animation));
}
} catch {
// skip
}
return d;
};
const animeDuration = (): number => {
let d = 0;
for (const instance of w.__hfAnime ?? []) {
d = Math.max(d, finiteMsToSeconds(instance.totalDuration ?? instance.duration));
}
return d;
};
let d = finiteSeconds(w.__player?.getDuration?.());
const root = document.querySelector("[data-composition-id][data-duration]");
if (root) d = Math.max(d, finiteSeconds(root.getAttribute("data-duration")));
return Math.max(d, timelinesDuration(), waapiDuration(), animeDuration());
});
}
// In the live DOM, decide which animated selectors fall under `scope`: read
// whether the scope exists and, for each candidate, whether it is the scope or a
// descendant of it. The pure decision (motionShotLayout.resolveShotSelectors)
// runs Node-side on the booleans this returns, so it stays unit-testable.
async function resolveScopeInBrowser(
page: import("puppeteer-core").Page,
scope: string,
candidates: string[],
): Promise<ScopeResolution> {
const probe = await page.evaluate(
(scopeSel: string, cands: string[]) => {
let root: Element | null = null;
try {
root = document.querySelector(scopeSel);
} catch {
root = null;
}
const descendant = cands.map((sel) => {
if (!root) return false;
let el: Element | null = null;
try {
el = document.querySelector(sel);
} catch {
return false;
}
return !!el && (el === root || root.contains(el));
});
return { scopeExists: !!root, descendant };
},
scope,
candidates,
);
const selectors = resolveShotSelectors(
scope,
candidates,
(_s, target) => probe.descendant[candidates.indexOf(target)] === true,
);
return { selectors, scopeExists: probe.scopeExists };
}
// --selector scope: the focused element is often a STATIC wrapper (`.clip`)
// whose animated children carry the tweens. Resolve, in the live DOM, to the
// scope itself if it animates, else its animated descendants — so the shot
// works on the standard composition shape instead of erroring.
async function resolveScopedRequests(
page: import("puppeteer-core").Page,
requests: ShotRequest[],
scopeSelector: string,
): Promise<ShotRequest[]> {
const resolved = await resolveScopeInBrowser(
page,
scopeSelector,
requests.map((r) => r.selector),
);
if (!resolved.scopeExists) {
throw new Error(`--shot: --selector '${scopeSelector}' matched no element.`);
}
if (resolved.selectors.length === 0) {
const nearest = requests
.slice(0, 5)
.map((r) => r.selector)
.join(", ");
throw new Error(
`--shot: nothing animates under '${scopeSelector}'. Nearest animated elements: ${nearest || "(none)"}.`,
);
}
return resolved.selectors.map((selector) => ({ selector }));
}
// In-tick capture: seek the timeline (fires the composition's onUpdate render
// synchronously via the shared window.__hfSeekAllAdapters) + nudge the
// three-adapter, then drawImage every <canvas> onto an offscreen canvas in the
// SAME tick — before the browser clears the GL drawing buffer (works without
// preserveDrawingBuffer; page.screenshot can't see the GL buffer here).
function captureGhostFrame(page: import("puppeteer-core").Page, t: number): Promise<string> {
return page.evaluate((tt: number) => {
(window as unknown as { __hfSeekAllAdapters?: (time: number) => void }).__hfSeekAllAdapters?.(
tt,
);
const root = (document.querySelector("[data-composition-id]") ?? document.body) as HTMLElement;
const rb = root.getBoundingClientRect();
const off = document.createElement("canvas");
off.width = Math.max(1, Math.round(rb.width));
off.height = Math.max(1, Math.round(rb.height));
const octx = off.getContext("2d");
for (const cv of Array.from(document.querySelectorAll("canvas"))) {
const r = cv.getBoundingClientRect();
try {
octx?.drawImage(cv, r.left - rb.left, r.top - rb.top, r.width, r.height);
} catch {
/* tainted / not ready — skip */
}
}
return off.toDataURL("image/png");
}, t);
}
// Shared by both onion-skin modes: rotate the composition to the requested
// orbit angle (a no-op at the front angle), and format that angle for the
// frame's caption label.
async function applyOrbitCameraIfAngled(
page: import("puppeteer-core").Page,
requests: ShotRequest[],
camera: OrbitCamera,
): Promise<void> {
if (camera.yaw === 0 && camera.pitch === 0) return;
await page.evaluate(
applyOrbitCamera,
requests.map((r) => r.selector),
camera,
);
}
function cameraLabel(camera: OrbitCamera): string {
return camera.yaw === 0 && camera.pitch === 0
? "front"
: `yaw ${camera.yaw}° pitch ${camera.pitch}°`;
}
// Rendered ("ghost") onion-skin: screenshot the REAL painted stage at each
// sample and composite them as translucent ghosts. This is the onion-skin for
// canvas/WebGL motion the marker sampler is blind to (the markers project a
// bbox; the pixels are the motion). Works for any visual composition, but
// requires a <canvas> (DOM/SVG transform motion already shows up in the
// default marker onion).
async function captureGhostOnionSkin(
page: import("puppeteer-core").Page,
requests: ShotRequest[],
times: number[],
size: FrameSize,
camera: OrbitCamera,
outPath: string,
): Promise<string> {
await applyOrbitCameraIfAngled(page, requests, camera);
const hasCanvas = await page.evaluate(() => document.querySelectorAll("canvas").length > 0);
if (!hasCanvas) {
throw new Error(
"--ghost renders a canvas/WebGL motion trail, but this composition has no <canvas>. Use the default --shot onion for DOM/SVG transform motion.",
);
}
const frames: string[] = [];
for (const t of times) {
frames.push(await captureGhostFrame(page, t));
}
const label = `${cameraLabel(camera)} · rendered onion · ${times.length} frames · t ${times[0]}${times[times.length - 1]}s`;
const dataUrl = (await page.evaluate(
compositeGhostFrames,
frames,
ghostAlphas(frames.length),
size.width,
size.height,
label,
)) as string;
const b64 = String(dataUrl).replace(/^data:image\/png;base64,/, "");
if (!b64) throw new Error("ghost composite returned no data");
writeFileSync(outPath, Buffer.from(b64, "base64"));
return outPath;
}
// Default (marker) onion-skin: seek to each sample time, read every element's
// projected corners. Marker children (zero-size) inherit the element's full
// transform chain, so their screen positions ARE the 3D projection of each
// corner — this is how 3D comes "for free" without an #stage assumption.
async function captureMarkerOnionSkin(
page: import("puppeteer-core").Page,
requests: ShotRequest[],
times: number[],
size: FrameSize,
camera: OrbitCamera,
frame: { layout: "path" | "strip"; fit: boolean; hasWindow: boolean },
outPath: string,
): Promise<string> {
// Orbit camera as its own step (keeps the sampler simple), only when angled.
await applyOrbitCameraIfAngled(page, requests, camera);
const elements = (await page.evaluate(
(selectors: string[], ts: number[]) => {
const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => void })
.__hfSeekAllAdapters;
const rigs = selectors.map((sel) => {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) return null;
const w = el.offsetWidth;
const h = el.offsetHeight;
const local: Array<[number, number]> = [
[0, 0],
[w, 0],
[w, h],
[0, h],
[w / 2, h / 2],
];
const markers = local.map(([lx, ly]) => {
const m = document.createElement("div");
m.style.cssText = `position:absolute;left:${lx}px;top:${ly}px;width:0;height:0;pointer-events:none`;
el.appendChild(m);
return m;
});
return { el, markers };
});
const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] }));
for (const t of ts) {
seek?.(t);
rigs.forEach((rig, i) => {
if (!rig) return;
const pts = rig.markers.map((m) => {
const r = m.getBoundingClientRect();
return { x: r.left, y: r.top };
});
const cs = getComputedStyle(rig.el);
out[i]!.samples.push({
t: Math.round(t * 1000) / 1000,
q: pts.slice(0, 4),
c: pts[4]!,
color: cs.backgroundColor,
opacity: parseFloat(cs.opacity) || 0,
});
});
}
rigs.forEach((rig) => {
if (rig) rig.el.style.visibility = "hidden";
});
return out.filter((o) => o.samples.length > 0);
},
requests.map((r) => r.selector),
times,
)) as OnionElement[];
const windowStr = frame.hasWindow ? ` · t ${times[0]}${times[times.length - 1]}s` : "";
const label = `${cameraLabel(camera)} · ${frame.layout === "strip" ? "filmstrip" : frame.fit ? "zoom-fit" : "1:1"} · ${times.length} frames${windowStr}`;
const svg = buildOnionSvg(elements, {
layout: frame.layout,
fit: frame.fit,
width: size.width,
height: size.height,
label,
});
await page.evaluate((markup: string) => {
document.body.insertAdjacentHTML("beforeend", markup);
}, svg);
await new Promise((r) => setTimeout(r, 60));
const buf = await page.screenshot({ type: "png" });
if (!buf) throw new Error("screenshot returned no data");
writeFileSync(outPath, buf as Uint8Array);
return outPath;
}
/** Render `projectDir`'s index headless, sample each element's motion as a 3D
* onion-skin, screenshot to `outPath` (PNG). Returns the saved path. */
export async function captureMotionPathShot(
projectDir: string,
requestsIn: ShotRequest[],
outPath: string,
opts: ShotOptions = {},
): Promise<string> {
let requests = requestsIn;
const samples = Math.max(1, Math.min(60, opts.samples ?? 9));
const layout = opts.layout ?? "path";
const fit = opts.fit ?? true;
const camera = parseAngle(opts.angle);
const { ensureBrowser } = await import("../browser/manager.js");
const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir);
const server = await serveStaticProjectHtml(
projectDir,
html,
"Failed to bind motion shot server",
);
let browserInstance: import("puppeteer-core").Browser | undefined;
try {
const browser = await ensureBrowser();
const opened = await openCompositionPage(server.url, browser.executablePath);
browserInstance = opened.browser;
const { page, size } = opened;
if (opts.scopeSelector && !opts.ghost) {
requests = await resolveScopedRequests(page, requests, opts.scopeSelector);
}
const times = sampleTimes(
await timelineDuration(page),
samples,
opts.from ?? null,
opts.to ?? null,
);
if (opts.ghost) {
return await captureGhostOnionSkin(page, requests, times, size, camera, outPath);
}
return await captureMarkerOnionSkin(
page,
requests,
times,
size,
camera,
{ layout, fit, hasWindow: opts.from != null || opts.to != null },
outPath,
);
} finally {
await browserInstance?.close().catch(() => {});
await server.close().catch(() => {});
}
}
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import {
buildOnionSvg,
fitTransform,
ghostAlphas,
parseAngle,
resolveShotSelectors,
sampleTimes,
stripCells,
type OnionElement,
} from "./motionShotLayout.js";
describe("ghostAlphas", () => {
it("ramps older→fainter, newest solid, monotonic increasing", () => {
expect(ghostAlphas(0)).toEqual([]);
expect(ghostAlphas(1)).toEqual([1]);
const a = ghostAlphas(5);
expect(a).toHaveLength(5);
expect(a[0]).toBeCloseTo(0.14, 3); // oldest faintest
expect(a[4]).toBe(1); // newest solid
for (let i = 1; i < a.length; i++) expect(a[i]).toBeGreaterThan(a[i - 1]!);
});
});
describe("resolveShotSelectors", () => {
const animated = [".title", ".cube", ".floor"];
it("samples the scope itself when the scope animates", () => {
// .cube has its own tween → exact selection, no descendant fallback.
const got = resolveShotSelectors(".cube", animated, () => {
throw new Error("descendant check should not run when scope animates");
});
expect(got).toEqual([".cube"]);
});
it("falls back to animated descendants when the scope is a static wrapper", () => {
// .clip is the standard composition root: static, but every animated element
// lives under it → resolve to all of them (this is VERIFIED BUG #9).
const got = resolveShotSelectors(".clip", animated, () => true);
expect(got).toEqual([".title", ".cube", ".floor"]);
});
it("keeps only the descendants that are actually under the scope", () => {
const underPanel = new Set([".title", ".floor"]);
const got = resolveShotSelectors(".panel", animated, (_scope, target) =>
underPanel.has(target),
);
expect(got).toEqual([".title", ".floor"]);
});
it("returns [] when nothing animates under the scope (caller errors)", () => {
expect(resolveShotSelectors(".empty", animated, () => false)).toEqual([]);
});
});
describe("sampleTimes", () => {
it("spreads N equal-time steps across the full duration", () => {
expect(sampleTimes(4, 5, null, null)).toEqual([0, 1, 2, 3, 4]);
});
it("samples only the requested window", () => {
expect(sampleTimes(4, 3, 2, 3)).toEqual([2, 2.5, 3]);
});
it("returns a single point at the window start when n=1", () => {
expect(sampleTimes(4, 1, 1.5, 3)).toEqual([1.5]);
});
it("clamps the window to [0, dur]", () => {
expect(sampleTimes(4, 2, -5, 99)).toEqual([0, 4]);
});
});
describe("fitTransform", () => {
it("centres on the bbox midpoint", () => {
const { cx, cy } = fitTransform(
[
{ x: 100, y: 200 },
{ x: 300, y: 400 },
],
1000,
1000,
);
expect(cx).toBe(200);
expect(cy).toBe(300);
});
it("zooms a tiny cluster up (k > 1) but clamps the factor", () => {
const { k } = fitTransform(
[
{ x: 0, y: 0 },
{ x: 10, y: 10 },
],
1000,
1000,
);
expect(k).toBeGreaterThan(1);
expect(k).toBeLessThanOrEqual(7);
});
it("shrinks an oversized span (k < 1)", () => {
const { k } = fitTransform(
[
{ x: 0, y: 0 },
{ x: 5000, y: 0 },
],
1000,
1000,
);
expect(k).toBeLessThan(1);
expect(k).toBeGreaterThanOrEqual(0.3);
});
it("is safe on empty input", () => {
expect(fitTransform([], 800, 600)).toEqual({ k: 1, cx: 400, cy: 300 });
});
});
describe("stripCells", () => {
it("uses a single row for few samples", () => {
expect(stripCells(3, 900, 900)).toMatchObject({ cols: 3, rows: 1 });
});
it("uses a roughly square grid for many samples", () => {
expect(stripCells(9, 900, 900)).toMatchObject({ cols: 3, rows: 3 });
expect(stripCells(13, 1080, 1080)).toMatchObject({ cols: 4, rows: 4 });
});
});
describe("parseAngle", () => {
it("resolves named presets", () => {
expect(parseAngle("iso")).toEqual({ yaw: 30, pitch: -22 });
expect(parseAngle("top")).toEqual({ yaw: 0, pitch: -68 });
});
it("parses yaw,pitch pairs", () => {
expect(parseAngle("45,-30")).toEqual({ yaw: 45, pitch: -30 });
});
it("falls back to front on missing or garbage input", () => {
expect(parseAngle()).toEqual({ yaw: 0, pitch: 0 });
expect(parseAngle("nonsense")).toEqual({ yaw: 0, pitch: 0 });
});
});
const sample = (t: number) => ({
t,
q: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
],
c: { x: 5, y: 5 },
color: "rgb(34, 211, 238)",
opacity: 1,
});
const oneElement: OnionElement[] = [{ selector: "#hero", samples: [sample(0), sample(2)] }];
describe("buildOnionSvg", () => {
it("path layout: one ghost per sample, a connecting path, and centre dots", () => {
const svg = buildOnionSvg(oneElement, { layout: "path", fit: true, width: 1000, height: 1000 });
expect(svg.startsWith("<svg")).toBe(true);
expect((svg.match(/<polygon/g) ?? []).length).toBe(2); // 2 ghosts
expect((svg.match(/<line/g) ?? []).length).toBe(3); // 2 ticks + 1 path segment
expect((svg.match(/<circle/g) ?? []).length).toBe(2); // 2 centre dots
});
it("strip layout: one framed cell per sample", () => {
const svg = buildOnionSvg(oneElement, {
layout: "strip",
fit: true,
width: 1000,
height: 1000,
});
expect((svg.match(/<rect/g) ?? []).length).toBe(2); // 2 cells
expect((svg.match(/<polygon/g) ?? []).length).toBe(2);
});
it("renders the caption label when provided", () => {
const svg = buildOnionSvg(oneElement, {
layout: "path",
fit: true,
width: 800,
height: 800,
label: "front · zoom-fit",
});
expect(svg).toContain("front");
});
it("is safe on empty input", () => {
const svg = buildOnionSvg([], { layout: "path", fit: true, width: 800, height: 800 });
expect(svg.startsWith("<svg")).toBe(true);
expect(svg.match(/<polygon/g)).toBeNull();
});
});
@@ -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;
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
describe("tailFrameTime", () => {
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
// Verified on the V4 3D artifact: t=8.0 of an 8s clip rendered blank white,
// t=7.76 rendered the final hero. 8 - 8*0.03 = 7.76.
expect(tailFrameTime(8)).toBeCloseTo(7.76, 5);
});
it("uses a 50ms floor for short clips", () => {
expect(tailFrameTime(1)).toBeCloseTo(0.95, 5); // 1 - 0.05 (floor beats 3%)
});
it("never goes negative", () => {
expect(tailFrameTime(0)).toBe(0);
});
});
describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
it("default frames: last point is the readable tail, never exact duration", () => {
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 });
expect(times).toHaveLength(5);
expect(times[0]).toBe(0);
expect(times[times.length - 1]).toBeCloseTo(7.76, 5);
expect(times[times.length - 1]).toBeLessThan(8); // not the blank exact-end
expect(appendedTail).toBe(false);
});
it("single frame samples the midpoint", () => {
expect(computeSnapshotTimes(8, { frames: 1 }).times).toEqual([4]);
});
it("explicit --at: keeps the user's times AND appends an end-of-timeline frame", () => {
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 2, 3] });
expect(times.slice(0, 3)).toEqual([1, 2, 3]);
expect(times[times.length - 1]).toBeCloseTo(7.76, 5);
expect(appendedTail).toBe(true);
});
it("explicit --at: does not double-add when the user already sampled the tail", () => {
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 7.76] });
expect(times).toEqual([1, 7.76]);
expect(appendedTail).toBe(false);
});
it("explicit --at: a sample at exact duration counts as the tail (no append)", () => {
const { appendedTail } = computeSnapshotTimes(8, { frames: 5, at: [1, 8] });
expect(appendedTail).toBe(false);
});
it("respects includeEnd:false opt-out for --at", () => {
const { times, appendedTail } = computeSnapshotTimes(8, {
frames: 5,
at: [1, 2],
includeEnd: false,
});
expect(times).toEqual([1, 2]);
expect(appendedTail).toBe(false);
});
});
+137 -8
View File
@@ -10,8 +10,42 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
import { parseAngle, type Camera } from "./motionShotLayout.js";
import type { Example } from "./_examples.js";
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
// the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]:
// snapshot only captured the composition's own head-on camera, so 3D depth /
// occlusion couldn't be verified). Same approach as motionShot's orbit camera:
// make the composition root + its ancestor chain preserve-3d, strip intermediate
// perspective, put one perspective on the root's parent (the lens) and rotate
// the root — works on any composition shape (no #stage assumption).
//
// Kept as a self-contained copy of motionShot.ts's `applyOrbitCamera` because
// that one is module-private; this is ~15 lines and sharing it would mean
// touching motionShot.ts (out of scope for this change).
function orbitStageSource(): string {
return `function(cam) {
var root = document.querySelector("[data-composition-id]")
|| document.querySelector("#stage")
|| document.body.firstElementChild
|| document.body;
var n = root;
while (n && n !== document.body) {
n.style.transformStyle = "preserve-3d";
n.style.perspective = "none";
n = n.parentElement;
}
root.style.transformStyle = "preserve-3d";
root.style.perspective = "none";
root.style.transformOrigin = "50% 50%";
root.style.transform = "rotateX(" + cam.pitch + "deg) rotateY(" + cam.yaw + "deg)";
var lens = root.parentElement || document.body;
lens.style.perspective = "1600px";
lens.style.perspectiveOrigin = "50% 50%";
}`;
}
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
* default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological
* clip (corrupt media, stalled network mount, codec edge case) cannot wedge
@@ -87,15 +121,76 @@ async function extractVideoFrameToBuffer(
export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot capture"],
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
];
/**
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
* artifact: t=8.0 of an 8s clip was pure white, t=7.76 showed the final hero).
* So the "final frame" must be sampled just-before-end. The blank tail observed
* spanned the last ~2.5% of the timeline, hence a 3%-of-duration nudge (floored
* at 50ms so very short clips still back off a readable amount).
*/
export function tailFrameTime(duration: number): number {
return Math.max(0, duration - Math.max(0.05, duration * 0.03));
}
/**
* Pick the seek positions to screenshot. Pure so the "tail is always captured"
* guarantee is unit-testable (FINDING [7]: evenly-spaced --at times skipped the
* final beat and short hero beats with no signal).
*
* - No --at: evenly-spaced frames, but the LAST point is moved off the exact
* duration to `tailFrameTime` so it isn't blank.
* - With --at: the user's exact times are honoured, plus a guaranteed
* end-of-timeline frame appended (unless `includeEnd` is false), so the tail
* is never silently skipped. A near-duplicate of the tail is not added twice.
*
* `appendedTail` flags that the readable-tail frame was added on top of the
* caller's request — used to warn that short sub-interval beats between samples
* may still be missed and need explicit --at.
*/
export function computeSnapshotTimes(
duration: number,
opts: { frames: number; at?: number[]; includeEnd?: boolean },
): { times: number[]; appendedTail: boolean } {
const includeEnd = opts.includeEnd !== false;
const tail = tailFrameTime(duration);
const round = (t: number) => Math.round(t * 1000) / 1000;
if (opts.at?.length) {
const times = opts.at.map(round);
// Only append if the user didn't already sample at/near the readable tail.
const hasTail = times.some((t) => Math.abs(t - tail) < 0.05 || t >= duration);
if (includeEnd && duration > 0 && !hasTail) {
return { times: [...times, round(tail)], appendedTail: true };
}
return { times, appendedTail: false };
}
const n = opts.frames;
if (n <= 1) return { times: [round(duration / 2)], appendedTail: false };
const times = Array.from({ length: n }, (_, i) => (i / (n - 1)) * duration);
// Replace the final (exact-duration, blank) point with the readable tail.
if (includeEnd) times[times.length - 1] = tail;
return { times: times.map(round), appendedTail: false };
}
/**
* Render key frames from a composition as PNG screenshots.
* The agent can Read these to verify its output visually.
*/
async function captureSnapshots(
projectDir: string,
opts: { frames?: number; timeout?: number; at?: number[]; outputDir?: string },
opts: {
frames?: number;
timeout?: number;
at?: number[];
outputDir?: string;
angle?: Camera;
includeEnd?: boolean;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
@@ -226,12 +321,25 @@ async function captureSnapshots(
return [];
}
// Calculate seek positions — explicit timestamps or evenly spaced
const positions: number[] = opts.at?.length
? opts.at
: numFrames === 1
? [duration / 2]
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
// Calculate seek positions — explicit timestamps or evenly spaced, always
// including a readable end-of-timeline frame (FINDING [7]).
const { times: positions, appendedTail } = computeSnapshotTimes(duration, {
frames: numFrames,
at: opts.at,
includeEnd: opts.includeEnd,
});
if (appendedTail) {
console.log(
` ${c.dim(`Note: added an end-of-timeline frame at ${positions[positions.length - 1]!.toFixed(2)}s. Short beats between your --at times may still be skipped — pass them explicitly.`)}`,
);
}
// Orthogonal camera (FINDING [10]) — re-applied after each seek inside the
// loop, since renderSeek may touch the stage's inline transform.
const cameraExpr =
opts.angle && (opts.angle.yaw !== 0 || opts.angle.pitch !== 0)
? `(${orbitStageSource()})(${JSON.stringify(opts.angle)})`
: null;
const snapshotDir = opts.outputDir ?? join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
@@ -319,6 +427,8 @@ async function captureSnapshots(
requestAnimationFrame(function() { requestAnimationFrame(finish); });
})`);
if (cameraExpr) await page.evaluate(cameraExpr);
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
const active = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]"))
@@ -460,6 +570,17 @@ export default defineCommand({
description: "Ms to wait for runtime to initialize (default: 5000)",
default: "5000",
},
angle: {
type: "string",
description:
"Orthogonal 3D camera for depth/occlusion checks: a preset (front|iso|top|side) or 'yaw,pitch' degrees. Tilts the whole stage before screenshotting (real pixels, not bbox markers).",
},
end: {
type: "boolean",
description:
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
default: true,
},
describe: {
type: "string",
description:
@@ -487,10 +608,16 @@ export default defineCommand({
? null
: String(args.describe);
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
: `${frames} frames`;
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`);
const angleLabel =
camera && (camera.yaw !== 0 || camera.pitch !== 0)
? ` ${c.dim(`(angle yaw ${camera.yaw}° pitch ${camera.pitch}°)`)}`
: "";
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}${angleLabel}`);
try {
const snapshotDir = args.output
@@ -501,6 +628,8 @@ export default defineCommand({
timeout,
at: atTimestamps,
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
});
if (paths.length === 0) {