mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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 (0–100). */
|
||||
pct: number;
|
||||
/** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */
|
||||
time: number;
|
||||
properties: Record<string, number | string>;
|
||||
}
|
||||
|
||||
interface SurfacedTween {
|
||||
id: string;
|
||||
target: string;
|
||||
method: string;
|
||||
group?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
end: number;
|
||||
/** "keyframes" (array/object form), "flat" (to/from), or "motionPath". */
|
||||
shape: "keyframes" | "flat" | "motionPath";
|
||||
keyframes: KeyframePoint[];
|
||||
/** x/y position points (gsap offsets) when this tween animates position. */
|
||||
path: Array<{ x: number; y: number }> | null;
|
||||
/** 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;
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user