diff --git a/packages/cli/src/cli.commands.test.ts b/packages/cli/src/cli.commands.test.ts new file mode 100644 index 0000000000..4a533eeb5b --- /dev/null +++ b/packages/cli/src/cli.commands.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const cliSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "cli.ts"), "utf8"); +const helpSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "help.ts"), "utf8"); + +function commandLoaderBlock(): string { + const match = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/); + expect(match).toBeTruthy(); + return match![1]!; +} + +describe("CLI command registration", () => { + it("registers keyframes as the only keyframe inspection command", () => { + const loaders = commandLoaderBlock(); + + expect(loaders).toMatch(/\bkeyframes:\s*\(\)\s*=>\s*import\("\.\/commands\/keyframes\.js"\)/); + expect(loaders).not.toMatch(/\bmotion:\s*\(\)\s*=>/); + expect(loaders).not.toContain("./commands/motion.js"); + }); + + it("shows keyframes in root help", () => { + expect(helpSource).toContain( + '["keyframes", "Inspect keyframes and render onion-shot diagnostics"]', + ); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 23261f206b..7b1ad85818 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -121,6 +121,7 @@ const commandLoaders = { lint: () => import("./commands/lint.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), + keyframes: () => import("./commands/keyframes.js").then((m) => m.default), layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), diff --git a/packages/cli/src/commands/info.test.ts b/packages/cli/src/commands/info.test.ts new file mode 100644 index 0000000000..1386216100 --- /dev/null +++ b/packages/cli/src/commands/info.test.ts @@ -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 = `
`; + expect(durationFromHtml(html, 5)).toBe(6); + }); + + it("reads data-duration regardless of attribute order", () => { + const html = `
`; + expect(durationFromHtml(html, 5)).toBe(8); + }); + + it("falls back to the computed timeline duration when no data-duration", () => { + const html = `
`; + expect(durationFromHtml(html, 5)).toBe(5); + }); +}); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index f5074d3757..f57d40f2a1 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -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 = {}; @@ -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))); diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts new file mode 100644 index 0000000000..39e5c3281f --- /dev/null +++ b/packages/cli/src/commands/keyframes.test.ts @@ -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) => + `
`; + +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) => + `
`; + + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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"])); + }); +}); diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts new file mode 100644 index 0000000000..b9b5f3df25 --- /dev/null +++ b/packages/cli/src/commands/keyframes.ts @@ -0,0 +1,922 @@ +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; +} + +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; + 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 | 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): Record { + const base: Record = {}; + 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): Record { + const out: Record = {}; + 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 }>; + 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 animationNamesFromDeclarations(body: string, knownNames: Set): string[] { + const out = new Set(); + 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 = raw.trim().replace(/^["']|["']$/g, ""); + if (knownNames.has(name)) out.add(name); + } + } + + const animationRe = /animation\s*:\s*([^;]+)/g; + let animationMatch: RegExpExecArray | null; + while ((animationMatch = animationRe.exec(body))) { + for (const raw of animationMatch[1]!.split(",")) { + const tokens = raw.trim().split(/\s+/); + for (const token of tokens) { + const normalized = token.replace(/^["']|["']$/g, ""); + if (knownNames.has(normalized)) out.add(normalized); + } + } + } + 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>(); + 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())], + keyframes: kf.stops, + })); +} + +function valuesFromProperty( + script: string, + property: "targets" | "duration", +): Array { + const values: Array = []; + 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(); + 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, 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(); + 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 { + 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. +export function collectShotSelectors(comps: SurfacedComposition[]): Array<{ selector: string }> { + const selectors = new Set(); + for (const cmp of comps) { + for (const tr of cmp.traces) selectors.add(tr.target); + for (const t of cmp.tweens) { + if (t.method !== "set") selectors.add(t.target); + } + for (const css of cmp.cssKeyframes) { + for (const selector of css.selectors) { + if (selector) selectors.add(selector); + } + } + for (const anime of cmp.anime) { + for (const selector of anime.targets) { + if (selector) selectors.add(selector); + } + } + } + return [...selectors].map((selector) => ({ selector })); +} + +/** 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 { + 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); + if (!projectDir) { + console.log(c.dim("--shot needs a project directory (not a single .html file).")); + return true; + } + // 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 && !args.ghost) { + console.log(c.dim("--shot: no animated element to sample for the selection.")); + return true; + } + const saved = await captureMotionPathShot(projectDir, requests, resolve(args.shot!), { + samples: num(args.samples) ?? 9, + layout: args.layout === "strip" ? "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, + }); + console.log(`${c.success("◇")} onion-skin screenshot saved ${c.accent(saved)}`); + console.log( + c.dim( + ` ${requests.length} element${requests.length === 1 ? "" : "s"} · open it to verify the motion matches your target, then read the keyframes below.`, + ), + ); + console.log(); + 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 }; +} + +// Print one composition's traces + tweens (skipping strokes already shown in a trace). +function printComposition(cmp: SurfacedComposition): void { + if ( + cmp.tweens.length === 0 && + cmp.traces.length === 0 && + cmp.cssKeyframes.length === 0 && + cmp.anime.length === 0 + ) + return; + console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`)); + 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 tr of cmp.traces) printTrace(tr); + 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); + } + 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 = {}) { + 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 ).", + 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(); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 76ca106a8a..119d332733 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -310,6 +310,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 []; @@ -320,7 +331,11 @@ const container = nearestConstraint(element, root, rootRect); const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect()); const containerOverflow = overflowFor(textRect, containerRect, tolerance); - if (containerOverflow && !hasAllowOverflowFlag(element)) { + if ( + containerOverflow && + !hasAllowOverflowFlag(element) && + !clippedByAncestor(element, container) + ) { const style = getComputedStyle(element); issues.push({ code: "text_box_overflow", @@ -520,12 +535,29 @@ return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element); } + // 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 or non-opaque. + // there is related to the text, non-opaque, or sharing a 3D context with it. 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; return isOpaqueOccluder(hit) ? hit : null; } diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts new file mode 100644 index 0000000000..d1d9168ff7 --- /dev/null +++ b/packages/cli/src/commands/motionShot.ts @@ -0,0 +1,655 @@ +// 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 . */ + ghost?: boolean; +} + +interface PageSample { + t: number; + q: Array<{ x: number; y: number }>; + c: { x: number; y: number }; + color: string; + opacity: 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: { yaw: number; pitch: number }): 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 { + 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(); + }); +} + +// 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: { width: number; height: number }; +}> { + 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 } }; + if (d.fonts?.ready) await d.fonts.ready; + }) + .catch(() => {}); + 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 { + 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 number; totalDuration?: () => number }>; + __hfAnime?: Array<{ duration?: number | string; totalDuration?: number | string }>; + }; + 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"))); + + const tls = Object.values(w.__timelines ?? {}); + for (const tl of tls) { + try { + d = Math.max(d, (tl.totalDuration?.() ?? tl.duration?.() ?? 0) as number); + } catch { + // skip + } + } + + if (typeof document.getAnimations === "function") { + try { + for (const animation of document.getAnimations()) { + const timing = animation.effect?.getTiming?.(); + if (!timing) continue; + const durationMs = Number(timing.duration); + const iterations = Number(timing.iterations ?? 1); + if (!Number.isFinite(durationMs) || !Number.isFinite(iterations)) continue; + const delayMs = Number(timing.delay ?? 0); + const endDelayMs = Number(timing.endDelay ?? 0); + d = Math.max( + d, + finiteMsToSeconds(Math.max(0, delayMs) + durationMs * iterations + endDelayMs), + ); + } + } catch { + // skip + } + } + + for (const instance of w.__hfAnime ?? []) { + d = Math.max(d, finiteMsToSeconds(instance.totalDuration ?? instance.duration)); + } + return d; + }); +} + +// 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 { + 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 }; +} + +/** 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 { + 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; + + // --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. + if (opts.scopeSelector && !opts.ghost) { + const resolved = await resolveScopeInBrowser( + page, + opts.scopeSelector, + requests.map((r) => r.selector), + ); + if (!resolved.scopeExists) { + throw new Error(`--shot: --selector '${opts.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 '${opts.scopeSelector}'. Nearest animated elements: ${nearest || "(none)"}.`, + ); + } + requests = resolved.selectors.map((selector) => ({ selector })); + } + + const times = sampleTimes( + await timelineDuration(page), + samples, + opts.from ?? null, + opts.to ?? null, + ); + + // ── 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. + if (opts.ghost) { + if (camera.yaw !== 0 || camera.pitch !== 0) { + await page.evaluate( + applyOrbitCamera, + requests.map((r) => r.selector), + camera, + ); + } + // --ghost is the rendered onion for canvas/WebGL motion — the case the + // marker onion is blind to. For DOM/SVG the default --shot onion already + // shows transform motion, so require a canvas here rather than ship a + // page.screenshot path that fights the runtime's virtual-time rAF. + 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 . Use the default --shot onion for DOM/SVG transform motion.", + ); + } + const frames: string[] = []; + for (const t of times) { + // In-tick capture: seek the timeline (fires the composition's onUpdate + // render synchronously) + nudge the three-adapter (hf-seek / __hfThreeTime + // / render hook), then drawImage the 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). + const dataUrl = await page.evaluate((tt: number) => { + 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); + try { + if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(tt); + else if (typeof w.__player?.seek === "function") w.__player.seek(tt); + } catch { + /* best-effort */ + } + Object.values(w.__timelines ?? {}).forEach((tl) => { + try { + tl.pause?.(); + if (typeof tl.totalTime === "function") { + tl.totalTime(tt + 0.001, true); + tl.totalTime(tt, false); + } else tl.seek?.(tt); + } catch { + /* best-effort */ + } + }); + try { + if (typeof document.getAnimations === "function") { + for (const animation of document.getAnimations()) { + try { + animation.currentTime = timeMs; + } catch { + /* best-effort */ + } + try { + animation.pause(); + } catch { + /* best-effort */ + } + } + } + } catch { + /* best-effort */ + } + for (const instance of w.__hfAnime ?? []) { + try { + instance.pause?.(); + instance.seek?.(timeMs); + } catch { + /* best-effort */ + } + } + try { + w.__hfThreeTime = tt; + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } })); + w.__hfThreeRender?.(); + w.gsap?.ticker?.tick?.(); + } catch { + /* best-effort */ + } + 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); + frames.push(dataUrl); + } + const camLabelG = + camera.yaw === 0 && camera.pitch === 0 + ? "front" + : `yaw ${camera.yaw}° pitch ${camera.pitch}°`; + const labelG = `${camLabelG} · 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, + labelG, + )) 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; + } + + // Orbit camera as its own step (keeps the sampler simple), only when angled. + if (camera.yaw !== 0 || camera.pitch !== 0) { + await page.evaluate( + applyOrbitCamera, + requests.map((r) => r.selector), + camera, + ); + } + + // Sample: seek to each 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. + const elements = (await page.evaluate( + (selectors: string[], ts: number[]) => { + const w = window as unknown as { + __player?: { renderSeek?: (t: number) => void; seek?: (t: number) => void }; + __hfAnime?: Array<{ pause?: () => void; seek?: (timeMs: number) => void }>; + __hfThreeTime?: number; + __hfThreeRender?: () => void; + gsap?: { ticker?: { tick?: () => void } }; + __timelines?: Record< + string, + { + pause?: () => void; + seek?: (t: number) => void; + totalTime?: (t: number, s: boolean) => void; + } + >; + }; + const tls = Object.values(w.__timelines ?? {}); + const seekAll = (t: number) => { + const timeMs = Math.max(0, t * 1000); + try { + if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(t); + else if (typeof w.__player?.seek === "function") w.__player.seek(t); + } catch { + // best-effort + } + tls.forEach((tl) => { + try { + tl.pause?.(); + if (typeof tl.totalTime === "function") { + tl.totalTime(t + 0.001, true); + tl.totalTime(t, false); + } else { + tl.seek?.(t); + } + } catch { + // best-effort + } + }); + try { + if (typeof document.getAnimations === "function") { + for (const animation of document.getAnimations()) { + try { + animation.currentTime = timeMs; + } catch { + // best-effort + } + try { + animation.pause(); + } catch { + // best-effort + } + } + } + } catch { + // best-effort + } + for (const instance of w.__hfAnime ?? []) { + try { + instance.pause?.(); + instance.seek?.(timeMs); + } catch { + // best-effort + } + } + try { + w.__hfThreeTime = t; + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: t } })); + w.__hfThreeRender?.(); + w.gsap?.ticker?.tick?.(); + } catch { + // best-effort + } + }; + + 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) { + seekAll(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 = + opts.from != null || opts.to != null ? ` · t ${times[0]}–${times[times.length - 1]}s` : ""; + const camLabel = + camera.yaw === 0 && camera.pitch === 0 + ? "front" + : `yaw ${camera.yaw}° pitch ${camera.pitch}°`; + const label = `${camLabel} · ${layout === "strip" ? "filmstrip" : fit ? "zoom-fit" : "1:1"} · ${times.length} frames${windowStr}`; + const svg = buildOnionSvg(elements, { + layout, + 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; + } finally { + await browserInstance?.close().catch(() => {}); + await server.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/motionShotLayout.test.ts b/packages/cli/src/commands/motionShotLayout.test.ts new file mode 100644 index 0000000000..3924c85da9 --- /dev/null +++ b/packages/cli/src/commands/motionShotLayout.test.ts @@ -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(" { + const svg = buildOnionSvg(oneElement, { + layout: "strip", + fit: true, + width: 1000, + height: 1000, + }); + expect((svg.match(/ { + 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(" = { + 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) => + Object.entries(o) + .map(([k, v]) => `${k}="${v}"`) + .join(" "); + +const polygon = (corners: Pt[], fill: string, fillOpacity: number, stroke: string) => + ` `${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) => + ``; + +const circle = (p: Pt, r: number, fill: string) => + ``; + +const text = (p: Pt, s: string, fill: string, size = 15) => + `${escapeXml(s)}`; + +const round = (n: number) => Math.round(n * 100) / 100; +const escapeXml = (s: string) => + s.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 `${body}`; +} + +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 += ``; + 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; +} diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts new file mode 100644 index 0000000000..0537f1705d --- /dev/null +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 81b947d5af..a4a606a26e 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -9,8 +9,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 @@ -86,15 +120,69 @@ 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[] }, + opts: { frames?: number; timeout?: number; at?: number[]; angle?: Camera; includeEnd?: boolean }, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); @@ -214,12 +302,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 = join(projectDir, "snapshots"); mkdirSync(snapshotDir, { recursive: true }); @@ -307,6 +408,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]")) @@ -424,6 +527,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: @@ -451,13 +565,25 @@ 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 paths = await captureSnapshots(project.dir, { frames, timeout, at: atTimestamps }); + const paths = await captureSnapshots(project.dir, { + frames, + timeout, + at: atTimestamps, + angle: camera, + includeEnd: args.end !== false, + }); if (paths.length === 0) { console.log( diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 28d589a0c8..5409f4f822 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -35,6 +35,7 @@ const GROUPS: Group[] = [ ["lint", "Validate a composition for common mistakes"], ["beats", "Detect beats in the music track and write beats/