diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 23261f206b..b936160bef 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), + motion: () => import("./commands/motion.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/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/motion.test.ts b/packages/cli/src/commands/motion.test.ts new file mode 100644 index 0000000000..e1a62cb77f --- /dev/null +++ b/packages/cli/src/commands/motion.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { ensureDOMParser } from "../utils/dom.js"; +import { surfaceComposition } from "./motion.js"; + +beforeAll(() => ensureDOMParser()); + +const wrap = (script: string) => + `
`; + +describe("motion 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("motion 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(); + }); +}); diff --git a/packages/cli/src/commands/motion.ts b/packages/cli/src/commands/motion.ts new file mode 100644 index 0000000000..ca6e93bdee --- /dev/null +++ b/packages/cli/src/commands/motion.ts @@ -0,0 +1,591 @@ +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 motion"], + ["Inspect one composition file", "hyperframes motion compositions/scene.html"], + ["Machine-readable output for an agent", "hyperframes motion --json"], + ["Only one element's tweens", "hyperframes motion --selector '#puck-a'"], +]; + +// ── Surfaced shapes ────────────────────────────────────────────────────────── + +interface KeyframePoint { + /** Tween-relative percentage (0–100). */ + pct: number; + /** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */ + time: number; + properties: Record; +} + +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 SurfacedComposition { + composition: string; + source: string; + tweens: SurfacedTween[]; + /** Multi-stroke traces: targets with ≥2 drawn position strokes, composited. */ + traces: SurfacedTrace[]; +} + +// ── GSAP extraction ────────────────────────────────────────────────────────── + +function inlineScriptText(html: string): string { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("script")) + .filter((s) => !s.getAttribute("src")) + .map((s) => s.textContent ?? "") + .join("\n"); +} + +function num(v: number | string | undefined): number | null { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isPositionTween(anim: GsapAnimation): boolean { + if (anim.propertyGroup === "position") return true; + const has = (p: Record | 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) }; +} + +// 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 }); +} + +// 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; + 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. +function collectAnimatedSelectors(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); + } + } + 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 = collectAnimatedSelectors(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)), + })) + .filter((cmp) => cmp.tweens.length > 0 || cmp.traces.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) 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); + } +} + +// ── Command ────────────────────────────────────────────────────────────────── + +export default defineCommand({ + meta: { + name: "motion", + description: + "See, debug, and refine motion — surface every GSAP tween, keyframe, and motion path, then --shot the onion-skin", + }, + args: { + target: { + type: "positional", + description: "Project dir or composition .html", + required: false, + }, + selector: { type: "string", description: "Only tweens matching this CSS selector" }, + json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false }, + 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 { comps, allComps, projectName, projectDir } = resolveScope(args); + + // --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, compositions: comps }), null, 2)); + return; + } + + const total = comps.reduce((n, cmp) => n + cmp.tweens.length, 0); + if (total === 0) { + console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no GSAP tweens found")}`); + return; + } + console.log( + `${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} tween${total === 1 ? "" : "s"}`)}`, + ); + console.log(); + for (const cmp of comps) printComposition(cmp); + console.log( + c.dim( + "Tip: edit the keyframes in source, then `motion --shot out.png` to see the rendered motion.", + ), + ); + }, +}); diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts new file mode 100644 index 0000000000..f1e96f7190 --- /dev/null +++ b/packages/cli/src/commands/motionShot.ts @@ -0,0 +1,524 @@ +// 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 paused timeline duration (seconds) across all registered timelines. +function timelineDuration(page: import("puppeteer-core").Page): Promise { + return page.evaluate(() => { + const tls = Object.values( + ( + window as unknown as { + __timelines?: Record number; totalDuration?: () => number }>; + } + ).__timelines ?? {}, + ); + let d = 0; + for (const tl of tls) { + try { + d = Math.max(d, (tl.totalDuration?.() ?? tl.duration?.() ?? 0) as number); + } catch { + // skip + } + } + 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 { + __hfThreeTime?: number; + __hfThreeRender?: () => void; + __timelines?: Record< + string, + { + pause?: () => void; + seek?: (t: number) => void; + totalTime?: (t: number, s: boolean) => void; + } + >; + }; + Object.values(w.__timelines ?? {}).forEach((tl) => { + try { + tl.pause?.(); + if (typeof tl.totalTime === "function") tl.totalTime(tt, false); + else tl.seek?.(tt); + } catch { + /* best-effort */ + } + }); + try { + w.__hfThreeTime = tt; + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } })); + w.__hfThreeRender?.(); + } 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 tls = Object.values( + ( + window as unknown as { + __timelines?: Record void; seek?: (t: number) => void }>; + } + ).__timelines ?? {}, + ); + const seekAll = (t: number) => + tls.forEach((tl) => { + try { + tl.pause?.(); + tl.seek?.(t); + } 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/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index f748992053..6c4ffc9704 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -3,6 +3,7 @@ import { buildLayoutSampleTimes, buildTransitionSampleTimes, computeOverflow, + overflowValueClips, collapseStaticLayoutIssues, limitLayoutIssues, mergeSampleTimes, @@ -165,6 +166,22 @@ describe("layoutAudit helpers", () => { expect(formatted).toContain("t=1-5s (3 samples)"); }); + // The clip rule that suppresses the odometer/ticker false positive: text + // spilling past an `overflow:hidden` reel window is the mechanism, not a bug. + it("treats clipping overflow values as masking (suppress) and visible as not (still report)", () => { + // Intended clipping — the queued digit rows of a reel are masked here. + expect(overflowValueClips("hidden")).toBe(true); + expect(overflowValueClips("clip")).toBe(true); + expect(overflowValueClips("auto")).toBe(true); + expect(overflowValueClips("scroll")).toBe(true); + // Genuine overflow — nothing masks the text, so it must STILL be reported. + expect(overflowValueClips("visible")).toBe(false); + expect(overflowValueClips("clip visible")).toBe(false); + expect(overflowValueClips("")).toBe(false); + expect(overflowValueClips(null)).toBe(false); + expect(overflowValueClips(undefined)).toBe(false); + }); + it("limits returned issues by severity before truncating", () => { const limited = limitLayoutIssues( [ diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 2826c1dcf5..70b00deb9c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -95,6 +95,18 @@ export function computeOverflow( return Object.keys(overflow).length > 0 ? overflow : null; } +/** + * Whether a computed `overflow*` value clips its box. Mirrors the rule the + * browser audit (layout-audit.browser.js) uses to decide that text spilling + * past such an ancestor is intentionally masked (odometer/ticker reels) rather + * than a `text_box_overflow` defect. Kept here as the one unit-testable seam of + * that suppression: only `visible` (and the `clip visible` no-op) must NOT clip + * — every clipping value must, or real masked overflow gets reported as a bug. + */ +export function overflowValueClips(value: string | null | undefined): boolean { + return !!value && value !== "visible" && value !== "clip visible"; +} + export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary { const errorCount = issues.filter((issue) => issue.severity === "error").length; const warningCount = issues.filter((issue) => issue.severity === "warning").length; diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 4ac76b32d7..f9d11ef0e8 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -169,6 +169,39 @@ describe("core rules", () => { const finding = result.findings.find((f) => f.code === "timeline_id_mismatch"); expect(finding).toBeUndefined(); }); + + it("accepts object-literal timeline registration and extracts its keys", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined(); + expect( + result.findings.find((f) => f.code === "timeline_registry_missing_init"), + ).toBeUndefined(); + // Key "comp-1" matches the DOM id, so no mismatch. + expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined(); + }); + + it("reports mismatched object-literal timeline registration keys", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timeline_id_mismatch"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('Timeline registered as "main"'); + }); }); it("warns when a timeline-visible element has no stable id for Studio editing", async () => { diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index 5a057ad4bf..271f22701f 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -9,6 +9,7 @@ import { getInlineScriptSyntaxError, TIMELINE_REGISTRY_INIT_PATTERN, TIMELINE_REGISTRY_ASSIGN_PATTERN, + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, INVALID_SCRIPT_CLOSE_PATTERN, } from "../utils"; @@ -93,7 +94,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ const findings: HyperframeLintFinding[] = []; if ( !TIMELINE_REGISTRY_INIT_PATTERN.test(source) && - !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) + !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && + !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source) ) { findings.push({ code: "missing_timeline_registry", diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 4e2d67b743..4de81d974c 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -28,6 +28,7 @@ import { stripJsComments, WINDOW_TIMELINE_ASSIGN_PATTERN, TIMELINE_REGISTRY_ASSIGN_PATTERN, + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, } from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -750,7 +751,9 @@ export const gsapRules: LintRule[] = [ for (const script of scripts) { const content = script.content; if (!/gsap\.timeline/.test(content)) continue; - const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content); + const hasRegistration = + WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || + TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content); if (hasRegistration || canInheritFromHost) continue; findings.push({ code: "gsap_timeline_not_registered", diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts index 2030a0658b..baefe699c6 100644 --- a/packages/core/src/lint/utils.ts +++ b/packages/core/src/lint/utils.ts @@ -21,6 +21,11 @@ export const SCRIPT_BLOCK_PATTERN = /]*)>([\s\S]*?)<\/script>/gi; const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g; export const TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i; +// Object-literal registration that assigns at least one `key: value` entry inline, +// e.g. `window.__timelines = { main: tl }` or `window.__timelines = { "comp-1": tl }`. +// Distinct from the empty-init form (`= {}`) — requires a key followed by `:`. +export const TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = + /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i; export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i; export const WINDOW_TIMELINE_ASSIGN_PATTERN = @@ -30,6 +35,14 @@ export const INVALID_SCRIPT_CLOSE_PATTERN = /]*>[\s\S]*?<\s*\/\s*scrip const TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g; +// The `window.__timelines = { ... }` object-literal body (group 1), captured so its +// `key: value` entries can be scanned for registered keys. +const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i; +// A single object-literal entry whose value is an identifier (real timeline registration), +// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare). +const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = + /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g; + export function extractOpenTags(source: string): OpenTag[] { const tags: OpenTag[] = []; let match: RegExpExecArray | null; @@ -156,6 +169,17 @@ export function extractTimelineRegistryKeys(source: string): string[] { const key = match[1] ?? match[2]; if (key) keys.add(key); } + const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1]; + if (objectBody) { + const entryPattern = new RegExp( + TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source, + TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags, + ); + while ((match = entryPattern.exec(objectBody)) !== null) { + const key = match[1] ?? match[2]; + if (key) keys.add(key); + } + } return [...keys]; } diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json index af592ef9dd..d1032f39b9 100644 --- a/packages/core/src/parsers/__goldens__/complex.parsed.json +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -14,6 +14,27 @@ "stagger": "__raw:0.055" }, "resolvedStart": 0.05, + "keyframes": { + "format": "percentage", + "keyframes": [ + { + "percentage": 0, + "properties": { + "y": 46, + "opacity": 0, + "stagger": 0.055 + } + }, + { + "percentage": 100, + "properties": { + "y": 0, + "opacity": 1, + "stagger": 0.055 + } + } + ] + }, "id": ".headline span-from-50" }, { @@ -56,6 +77,27 @@ "stagger": "__raw:0.08" }, "resolvedStart": 0.16, + "keyframes": { + "format": "percentage", + "keyframes": [ + { + "percentage": 0, + "properties": { + "scaleX": 0, + "opacity": 0, + "stagger": 0.08 + } + }, + { + "percentage": 100, + "properties": { + "scaleX": 1, + "opacity": 1, + "stagger": 0.08 + } + } + ] + }, "id": ".ambient-line-from-160" } ], diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/core/src/parsers/__goldens__/minimal.parsed.json index cbfa33de9a..6daf5e7fb1 100644 --- a/packages/core/src/parsers/__goldens__/minimal.parsed.json +++ b/packages/core/src/parsers/__goldens__/minimal.parsed.json @@ -8,6 +8,10 @@ "x": 0, "opacity": 1 }, + "fromProperties": { + "x": 420, + "opacity": 0 + }, "duration": 0.5, "ease": "power3.out", "resolvedStart": 0.2, @@ -21,6 +25,10 @@ "x": 420, "opacity": 0 }, + "fromProperties": { + "x": 0, + "opacity": 1 + }, "duration": 0.3, "ease": "power3.in", "resolvedStart": 4.2, diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/core/src/parsers/__goldens__/moderate.parsed.json index 8edb3f3506..8d269daf0f 100644 --- a/packages/core/src/parsers/__goldens__/moderate.parsed.json +++ b/packages/core/src/parsers/__goldens__/moderate.parsed.json @@ -8,6 +8,10 @@ "y": 0, "opacity": 1 }, + "fromProperties": { + "y": 300, + "opacity": 0 + }, "duration": 0.5, "ease": "power3.out", "resolvedStart": 0.1, @@ -33,6 +37,9 @@ "properties": { "scale": 1 }, + "fromProperties": { + "scale": 0.92 + }, "duration": 0.4, "ease": "elastic.out(1, 0.4)", "propertyGroup": "scale", @@ -73,6 +80,10 @@ "y": 300, "opacity": 0 }, + "fromProperties": { + "y": 0, + "opacity": 1 + }, "duration": 0.25, "ease": "power3.in", "resolvedStart": 3.8, diff --git a/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts b/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts new file mode 100644 index 0000000000..db2e9ac9bb --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts @@ -0,0 +1,194 @@ +/** + * Motion-introspection eval regressions (the `hyperframes motion` read path). + * + * Each block targets one bug the tooling eval surfaced where the parser was + * blind to authored motion. Minimal inline scripts reproduce the wrong output, + * then assert the fixed behavior. + */ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart; + +// ── Bug #2: constant expression folding (member / array-index / Math.*) ─────── + +describe("motion eval — constant expression folding (#2)", () => { + it("folds object-member access (H.bar0 / 200)", () => { + const { animations } = parseGsapScriptAcorn(` + const H = { bar0: 100, bar1: 50 }; + const tl = gsap.timeline({ paused: true }); + tl.to("#bar0", { scaleY: H.bar0 / 200, duration: 0.7 }, 0.3); + `); + expect(animations[0]!.properties.scaleY).toBe(0.5); + }); + + it("folds nested array index (SPARK[0][1])", () => { + const { animations } = parseGsapScriptAcorn(` + const SPARK = [[10, 20], [30, 40]]; + const tl = gsap.timeline({ paused: true }); + tl.to("#m", { x: SPARK[0][1], y: SPARK[1][0], duration: 0.3 }, 0); + `); + expect(animations[0]!.properties.x).toBe(20); + expect(animations[0]!.properties.y).toBe(30); + }); + + it("folds whitelisted Math.* over constant args", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to("#m", { x: Math.round(2.6), y: Math.max(3, 7), r: Math.PI, duration: 0.3 }, 0); + `); + expect(animations[0]!.properties.x).toBe(3); + expect(animations[0]!.properties.y).toBe(7); + expect(animations[0]!.properties.r).toBeCloseTo(Math.PI); + }); + + it("leaves genuinely runtime-dynamic values as __raw", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to("#m", { x: someFn(), duration: 0.3 }, 0); + `); + expect(String(animations[0]!.properties.x)).toMatch(/^__raw:/); + }); +}); + +// ── Bug #1: gsap.set() pre-state seeds the next tween's from-keyframe ────────── + +describe("motion eval — set() seeds tween start (#1)", () => { + it("seeds scaleY:0 from gsap.set before a .to(scaleY:1)", () => { + const { animations } = parseGsapScriptAcorn(` + gsap.set("#bar", { scaleY: 0 }); + const tl = gsap.timeline({ paused: true }); + tl.to("#bar", { scaleY: 1, duration: 0.7 }, 0.3); + `); + const grow = animations.find((a) => a.targetSelector === "#bar" && a.method === "to")!; + expect(grow.fromProperties?.scaleY).toBe(0); + }); + + it("uses the most recent set() value and only for omitted from-props", () => { + const { animations } = parseGsapScriptAcorn(` + gsap.set("#x", { opacity: 0, y: 8 }); + const tl = gsap.timeline({ paused: true }); + tl.set("#x", { opacity: 0.2 }, 0); + tl.to("#x", { opacity: 1, y: 0, duration: 0.5 }, 0.3); + `); + const t = animations.find((a) => a.method === "to")!; + expect(t.fromProperties?.opacity).toBe(0.2); // most recent set wins + expect(t.fromProperties?.y).toBe(8); + }); +}); + +// ── Bug #3: labels & label-relative positions ───────────────────────────────── + +describe("motion eval — label-relative positions (#3)", () => { + it("resolves numeric-label and label+=n positions to absolute times", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to("#c", { x: 10, duration: 1.1 }, 0); + tl.addLabel("press", 1.1); + tl.to("#c", { x: 20, duration: 0.12 }, "press"); + tl.to("#b", { scale: 1, duration: 0.32 }, "press+=0.12"); + tl.addLabel("loading", "press+=0.18"); + tl.to("#l", { opacity: 1, duration: 0.2 }, "loading"); + `); + const bySel = (s: string, i = 0) => animations.filter((a) => a.targetSelector === s)[i]!; + expect(start(bySel("#c", 1))).toBeCloseTo(1.1); + expect(start(bySel("#b"))).toBeCloseTo(1.22); + expect(start(bySel("#l"))).toBeCloseTo(1.28); // press(1.1)+0.18 + }); +}); + +// ── Bug #4: collection aliases (slice / index) keep the selector ────────────── + +describe("motion eval — collection aliases (#4)", () => { + it("resolves selector for glyphs.slice() / glyphs[0] aliases of toArray()", () => { + const { animations } = parseGsapScriptAcorn(` + const glyphs = gsap.utils.toArray(".glyph"); + const lead = glyphs[0]; + const rest = glyphs.slice(1); + const tl = gsap.timeline({ paused: true }); + tl.to(lead, { y: 0, duration: 0.5 }, 0.2); + tl.to(rest, { y: 0, duration: 0.6, stagger: 0.085 }, 0.5); + `); + expect(animations[0]!.targetSelector).toBe(".glyph"); + expect(animations[1]!.targetSelector).toBe(".glyph"); + expect(animations[1]!.hasUnresolvedSelector).toBeUndefined(); + // stagger still preserved in extras + expect(animations[1]!.extras?.stagger).toBeDefined(); + }); +}); + +// ── Bug: staggered collection tweens read as flat no-ops ────────────────────── + +describe("motion eval — staggered collection tweens are honest", () => { + it("surfaces real from/to + stagger for a staggered .from reveal (not a flat no-op)", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.from(".glyph", { yPercent: 120, stagger: 0.08, duration: 0.5 }, 0); + `); + const a = animations[0]!; + // Surfaced as keyframes so the `motion` text shows real per-element motion. + const kfs = a.keyframes?.keyframes ?? []; + expect(kfs.length).toBeGreaterThan(0); + // from() plays vars → rest: 120 → 0, a real non-no-op move. + expect(kfs[0]!.properties.yPercent).toBe(120); + expect(kfs.at(-1)!.properties.yPercent).toBe(0); + // The per-element stagger is noted on the surfaced keyframes. + expect(kfs.every((k) => k.properties.stagger === 0.08)).toBe(true); + // Per-element duration preserved; selector + extras untouched (round-trip safe). + expect(a.duration).toBe(0.5); + expect(a.targetSelector).toBe(".glyph"); + expect(a.extras?.stagger).toBeDefined(); + }); + + it("notes the stagger even when a .to lands on the rest pose (1→1 case)", () => { + // to(rest-pose) reads as 1→1; without the stagger note it looks like a no-op. + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to(".glyph", { scaleY: 1, scaleX: 1, yPercent: 0, duration: 0.18, + stagger: { each: 0.012, from: "center" } }, 0); + `); + const a = animations[0]!; + const kfs = a.keyframes?.keyframes ?? []; + expect(kfs.length).toBeGreaterThan(0); + // The collection still reads as animating: the per-element stagger is shown. + expect(kfs.every((k) => k.properties.stagger === 0.012)).toBe(true); + expect(a.duration).toBe(0.18); + }); + + it("leaves a non-staggered single tween untouched (no synthetic keyframes)", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { x: 100, duration: 0.4 }, 0); + `); + expect(animations[0]!.targetSelector).toBe("#hero"); + expect(animations[0]!.keyframes).toBeUndefined(); + }); +}); + +// ── Bug #11 / #5: dwell tweens & onUpdate proxy clarity ─────────────────────── + +describe("motion eval — dwell / proxy targets (#11, #5)", () => { + it("labels empty-target dwell tweens instead of __unresolved__", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + tl.to({}, { duration: 2.8 }, 2.2); + `); + const dwell = animations[0]!; + expect(dwell.targetSelector).not.toBe("__unresolved__"); + expect(dwell.targetSelector).toContain("dwell"); + expect(dwell.duration).toBe(2.8); + }); + + it("labels an onUpdate proxy tween with its driven DOM property", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline({ paused: true }); + const s = { u: 0 }; + tl.to(s, { u: 1, duration: 2.2, onUpdate: function () { + document.querySelector("#trace").setAttribute("stroke-dashoffset", s.u); + } }, 0.2); + `); + const drv = animations[0]!; + expect(drv.targetSelector).not.toBe("__unresolved__"); + expect(drv.targetSelector).toContain("stroke-dashoffset"); + }); +}); diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 34dadaeb1e..cd5bf2c5eb 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -50,6 +50,82 @@ type ScopeBindings = ReadonlyMap; /** Per-scope element bindings: scopeNode → (variable name → selector). */ type TargetBindings = Map>; +/** + * Side-table of top-level const/let ARRAY and OBJECT literals (of literals), + * captured by `collectScopeBindings` and stashed on the scope Map so that + * `resolveNode` can fold member access (`H.bar0`) and computed array index + * (`SPARK[0][1]`) without changing the resolveNode signature at its ~15 call + * sites. ponytail: hidden prop on the Map beats threading a new param everywhere. + */ +const CONST_NODES = Symbol("hf.constNodes"); +type ConstNodes = Map; + +function constNodesOf( + scope: ReadonlyMap, +): ConstNodes | undefined { + return (scope as any)[CONST_NODES]; +} + +/** Whitelisted Math members we fold over constant args (deterministic, no I/O). */ +const MATH_FNS = new Set(["min", "max", "round", "floor", "ceil", "abs", "sqrt", "sign", "trunc"]); +const MATH_CONSTS: Record = { PI: Math.PI, E: Math.E, SQRT2: Math.SQRT2 }; + +/** + * Fold a MemberExpression (`obj.prop`, `arr[i]`, `Math.PI`, `Math.round(x)`) + * against the const-node side-table. Returns undefined when not statically + * resolvable (genuinely runtime-dynamic) so the caller falls back to __raw. + */ +// fallow-ignore-next-line complexity +function resolveMemberNode( + node: any, + scope: ReadonlyMap, +): number | string | boolean | undefined { + // Math.PI / Math.E + if (node.object?.type === "Identifier" && node.object.name === "Math") { + const key = node.property?.name; + return typeof key === "string" ? MATH_CONSTS[key] : undefined; + } + // Resolve the object to a const AST node (array/object literal), descending + // through chained member/index access (SPARK[0][1], H.bar0). + const objNode = resolveConstNode(node.object, scope); + if (!objNode) return undefined; + let valueNode: any; + if (node.computed) { + const idx = resolveNode(node.property, scope); + if (objNode.type === "ArrayExpression" && typeof idx === "number") { + valueNode = objNode.elements?.[idx]; + } else if ( + objNode.type === "ObjectExpression" && + (typeof idx === "string" || typeof idx === "number") + ) { + valueNode = findPropertyNode(objNode, String(idx)); + } + } else if (objNode.type === "ObjectExpression") { + valueNode = findPropertyNode(objNode, node.property?.name ?? node.property?.value); + } + return valueNode ? resolveNode(valueNode, scope) : undefined; +} + +/** Resolve an expression to a const ARRAY/OBJECT AST node (for nested access). */ +function resolveConstNode(node: any, scope: ReadonlyMap): any { + if (!node) return undefined; + if (node.type === "ArrayExpression" || node.type === "ObjectExpression") return node; + if (node.type === "Identifier") return constNodesOf(scope)?.get(node.name); + if (node.type === "MemberExpression") { + const objNode = resolveConstNode(node.object, scope); + if (!objNode) return undefined; + if (node.computed) { + const idx = resolveNode(node.property, scope); + if (objNode.type === "ArrayExpression" && typeof idx === "number") + return objNode.elements?.[idx]; + if (objNode.type === "ObjectExpression") return findPropertyNode(objNode, String(idx)); + } else if (objNode.type === "ObjectExpression") { + return findPropertyNode(objNode, node.property?.name ?? node.property?.value); + } + } + return undefined; +} + // ── Value resolution ───────────────────────────────────────────────────────── // fallow-ignore-next-line complexity @@ -95,6 +171,22 @@ function resolveNode( if (node.type === "TemplateLiteral" && node.expressions?.length === 0) { return node.quasis?.[0]?.value?.cooked ?? undefined; } + if (node.type === "MemberExpression") { + return resolveMemberNode(node, scope); + } + // Whitelisted Math.fn(...) over constant args (Math.round/min/max/...). + if ( + node.type === "CallExpression" && + node.callee?.type === "MemberExpression" && + node.callee.object?.type === "Identifier" && + node.callee.object.name === "Math" && + MATH_FNS.has(node.callee.property?.name) + ) { + const args = (node.arguments ?? []).map((a: any) => resolveNode(a, scope)); + if (args.every((a: unknown) => typeof a === "number")) { + return (Math as any)[node.callee.property.name](...(args as number[])); + } + } return undefined; } @@ -193,14 +285,21 @@ function resolveCollectionSelector( function collectScopeBindings(ast: any): ScopeBindings { const bindings = new Map(); + // Const ARRAY/OBJECT literals are kept as AST nodes for member/index folding + // (resolveMemberNode), exposed to resolveNode via the CONST_NODES side-table. + const constNodes: ConstNodes = new Map(); + Object.defineProperty(bindings, CONST_NODES, { value: constNodes, enumerable: false }); acornWalk.simple(ast, { VariableDeclarator(node: any) { const name = node.id?.name; const init = node.init; - if (name && init) { - const val = resolveNode(init, bindings); - if (val !== undefined) bindings.set(name, val); + if (!name || !init) return; + if (init.type === "ArrayExpression" || init.type === "ObjectExpression") { + constNodes.set(name, init); + return; } + const val = resolveNode(init, bindings); + if (val !== undefined) bindings.set(name, val); }, }); return bindings; @@ -256,6 +355,40 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings { }, } as any); + // Pass 3: collection ALIASES inherit their source collection's selector. + // `const lead = glyphs[0]`, `const rest = glyphs.slice(1)`, + // `const some = glyphs.filter(...)` all still target the same selector + // (`.glyph`). Per-element identity isn't statically recoverable (DOM-sized + // collection), but the selector + stagger should not read as __unresolved__. + const COLLECTION_ALIAS_METHODS = new Set(["slice", "filter", "concat", "reverse"]); + acornWalk.ancestor(ast, { + // fallow-ignore-next-line complexity + VariableDeclarator(node: any, _: unknown, ancestors: any[]) { + const name = node.id?.name; + const init = node.init; + if (!name || !init) return; + let sourceVar: string | undefined; + // x = coll[i] + if (init.type === "MemberExpression" && init.object?.type === "Identifier") { + sourceVar = init.object.name; + } + // x = coll.slice(...) / coll.filter(...) + else if ( + init.type === "CallExpression" && + init.callee?.type === "MemberExpression" && + init.callee.object?.type === "Identifier" && + init.callee.property?.type === "Identifier" && + COLLECTION_ALIAS_METHODS.has(init.callee.property.name) + ) { + sourceVar = init.callee.object.name; + } + if (!sourceVar) return; + const selector = lookupBindingFromAncestors(sourceVar, ancestors, bindings); + if (selector) + addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), name, selector); + }, + } as any); + return bindings; } @@ -288,6 +421,63 @@ function resolveTargetSelector( return null; } +/** + * Classify an otherwise-unresolved tween target that is a plain object literal + * (`tl.to({}, …)`) or a proxy object (`tl.to(s, {onUpdate})`). Returns a + * descriptive pseudo-selector or null when the target isn't an object proxy. + * + * - Empty object literal → "dwell/hold" (a timing-only spacer tween, #11). + * - Proxy with onUpdate that writes a DOM attribute/style → "proxy → " + * (best-effort #5; the channel name is parsed from the onUpdate body). + */ +function describeProxyTarget(targetNode: any, varsNode: any, scope: ScopeBindings): string | null { + // Resolve an Identifier proxy (const s = {u:0}) to its object literal. + const objNode = + targetNode?.type === "ObjectExpression" + ? targetNode + : targetNode?.type === "Identifier" + ? resolveConstNode(targetNode, scope) + : undefined; + if (objNode?.type !== "ObjectExpression") return null; + + const onUpdate = findPropertyNode(varsNode, "onUpdate"); + const driven = onUpdate ? drivenDomChannel(onUpdate) : undefined; + if (driven) return `proxy → ${driven}`; + // Empty / proxy object with no resolvable DOM write ⇒ a timing spacer. + return "dwell/hold"; +} + +/** Best-effort: find the DOM attribute/style channel an onUpdate body writes. */ +// fallow-ignore-next-line complexity +function drivenDomChannel(fnNode: any): string | undefined { + let found: string | undefined; + acornWalk.simple(fnNode, { + CallExpression(node: any) { + // setAttribute("d" | "points" | "stroke-dashoffset", …) + if ( + node.callee?.type === "MemberExpression" && + node.callee.property?.name === "setAttribute" && + typeof node.arguments?.[0]?.value === "string" + ) { + found ??= node.arguments[0].value; + } + }, + AssignmentExpression(node: any) { + // el.style.foo = … / el.setAttribute-less style writes + const left = node.left; + if ( + left?.type === "MemberExpression" && + left.object?.type === "MemberExpression" && + left.object.property?.name === "style" && + left.property?.name + ) { + found ??= `style.${left.property.name}`; + } + }, + }); + return found; +} + // ── ObjectExpression utilities ──────────────────────────────────────────────── function isObjectProperty(prop: any): boolean { @@ -904,8 +1094,16 @@ function tweenCallToAnimation( duration = computeKeyframesTotalDuration(call.varsArg, scope, source); } + // Relabel object-proxy / empty-target tweens so they don't read as bare + // __unresolved__: a dwell/hold spacer or an onUpdate-driven DOM channel (#5/#11). + let selector = call.selector; + if (selector === "__unresolved__") { + const proxyLabel = describeProxyTarget(call.node.arguments?.[0], call.varsArg, scope); + if (proxyLabel) selector = proxyLabel; + } + const anim: Omit = { - targetSelector: call.selector, + targetSelector: selector, method: call.method, position, properties, @@ -927,12 +1125,88 @@ function tweenCallToAnimation( if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; - if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; + if (selector === "__unresolved__") anim.hasUnresolvedSelector = true; const provenance = readProvenance(call.node); if (provenance) anim.provenance = provenance; return anim; } +// ── Stagger annotation (read path) ──────────────────────────────────────────── + +/** + * Pull the per-element stagger amount out of a captured `extras.stagger` value. + * GSAP staggers are either a number (`0.08`) or an object (`{ each: 0.012, … }`). + * The value arrives here as the round-trip form `__raw:`; we only need + * the leading numeric / `each:` figure for the annotation. Returns undefined for + * non-numeric (function) staggers — there's nothing honest to print. + */ +function staggerAmount(raw: unknown): number | undefined { + if (typeof raw === "number") return raw; + if (typeof raw !== "string") return undefined; + const src = raw.startsWith("__raw:") ? raw.slice(6) : raw; + const m = /(?:each\s*:\s*)?(-?\d+(?:\.\d+)?)/.exec(src); + if (!m) return undefined; + const n = Number.parseFloat(m[1]!); + return Number.isFinite(n) ? n : undefined; +} + +/** Rest-pose value GSAP animates to/from for an unspecified endpoint. */ +function restValue(prop: string): number { + return prop === "opacity" || prop.startsWith("scale") ? 1 : 0; +} + +/** + * Honest from/to keyframes for a flat staggered tween, mirroring how a flat + * tween renders: `from()` plays vars → rest, `to()` plays rest → vars, `fromTo` + * plays its authored endpoints. A synthetic `stagger` channel rides on both + * keyframes so the per-element cascade is visible even when the tween lands on + * the rest pose (a 1→1 collection that would otherwise read as a no-op). + */ +function staggeredKeyframes( + anim: Omit, + each: number, +): GsapPercentageKeyframe[] { + const vars = { ...anim.properties }; + let from: Record; + let to: Record; + if (anim.method === "fromTo") { + from = { ...(anim.fromProperties ?? {}) }; + to = vars; + } else if (anim.method === "from") { + from = vars; + to = {}; + for (const k of Object.keys(vars)) to[k] = restValue(k); + } else { + from = { ...(anim.fromProperties ?? {}) }; + for (const k of Object.keys(vars)) if (from[k] === undefined) from[k] = restValue(k); + to = vars; + } + return [ + { percentage: 0, properties: { ...from, stagger: each } }, + { percentage: 100, properties: { ...to, stagger: each } }, + ]; +} + +/** + * Read-path honesty pass: a tween with a `stagger` targets a per-element + * collection that animates one element at a time. A flat staggered tween that + * lands on (or starts from) the rest pose otherwise renders as a misleading + * X→X no-op. Surface real from/to keyframes plus the per-element `stagger` + * amount so the reader can tell the collection DOES animate and roughly how. + * + * Read path only — the write/serialize path ignores `keyframes` and keeps the + * literal `extras.stagger` source, so round-trips are untouched. Keyframed / + * motionPath tweens already surface their motion, so they're left alone. + */ +function annotateStaggeredCollections(anims: Omit[]): void { + for (const anim of anims) { + if (anim.keyframes || anim.arcPath) continue; + const each = staggerAmount(anim.extras?.stagger); + if (each === undefined) continue; + anim.keyframes = { format: "percentage", keyframes: staggeredKeyframes(anim, each) }; + } +} + // ── Timeline position resolution ───────────────────────────────────────────── const GSAP_DEFAULT_DURATION = 0.5; @@ -963,6 +1237,89 @@ function resolvePositionString(pos: string, cursor: number, prevStart: number): return Number.isFinite(n) ? n : null; } +// ── set() pre-state seeding (#3 in eval) ────────────────────────────────────── + +/** + * Collect `gsap.set(target, {prop: v})` calls into selector → {prop: value}. + * These run before the (paused) timeline builds, establishing the initial DOM + * state. tl.set(...) calls are already in the animation list, so they're folded + * in during the seeding walk instead. + */ +function collectGsapSetStates( + ast: any, + scope: ScopeBindings, + bindings: TargetBindings, + source: string, +): Map> { + const states = new Map>(); + acornWalk.ancestor(ast, { + // fallow-ignore-next-line complexity + CallExpression(node: any, _: unknown, ancestors: any[]) { + const callee = node.callee; + if ( + callee?.type !== "MemberExpression" || + callee.object?.name !== "gsap" || + callee.property?.name !== "set" + ) + return; + const selector = resolveTargetSelector(node.arguments?.[0], ancestors, scope, bindings); + if (!selector) return; + const rec = objectExpressionToRecord(node.arguments?.[1], scope, source); + const props: Record = states.get(selector) ?? {}; + for (const [k, v] of Object.entries(rec)) { + if (typeof v === "number" || typeof v === "string") props[k] = v; + } + states.set(selector, props); + }, + } as any); + return states; +} + +/** + * Seed each tween's start keyframe from the most recent set() value on the same + * target. Without this, `set(scaleY:0)` then `.to(scaleY:1)` reports the CSS + * default as the start, so a grow/fold/fade-from reads as a flat no-op. + * + * Walks animations in timeline order, tracking per-target current state from + * gsap.set() (pre-seeded) and tl.set() tweens. For a later to/from tween that + * lacks an explicit from-value for an animated prop, the tracked set() value + * becomes its from-keyframe. + */ +function seedSetStates( + anims: Omit[], + initial: Map>, +): void { + const state = new Map>(); + for (const [sel, props] of initial) state.set(sel, { ...props }); + + for (const anim of anims) { + const sel = anim.targetSelector; + if (anim.method === "set") { + const cur = state.get(sel) ?? {}; + for (const [k, v] of Object.entries(anim.properties)) cur[k] = v; + state.set(sel, cur); + continue; + } + const cur = state.get(sel); + // fromTo authors its own start explicitly — don't override it. + if (anim.method === "to" && cur) { + const from = { ...(anim.fromProperties ?? {}) }; + let seeded = false; + for (const prop of Object.keys(anim.properties)) { + if (from[prop] === undefined && cur[prop] !== undefined) { + from[prop] = cur[prop]; + seeded = true; + } + } + if (seeded) anim.fromProperties = from; + } + // After a to/from tween, the target's state is the tween's END values. + const next = state.get(sel) ?? {}; + for (const [k, v] of Object.entries(anim.properties)) next[k] = v; + state.set(sel, next); + } +} + function applyTimelineDefaults( anims: Omit[], defaults?: TimelineDefaults, @@ -979,10 +1336,69 @@ function applyTimelineDefaults( } } -function resolveTimelinePositions(anims: Omit[]): void { +/** A source-ordered addLabel(name, position) definition. */ +interface AddLabelDef { + name: string; + /** Raw position string/number, or undefined ⇒ label sits at current end. */ + position: string | number | undefined; + /** Source-order key (parallel to anims), for interleaving. */ + order: number; +} + +/** + * Resolve a label-relative position string against a live label table. + * Handles "label", "label+=n", "label-=n". Unknown labels auto-create at the + * current playhead (cursor) — GSAP's behavior when a tween references a label + * that hasn't been added yet. Returns null when not a label form. + */ +function resolveLabelPosition( + pos: string, + labels: Map, + cursor: number, +): number | null { + const m = /^([A-Za-z_$][\w$]*)\s*(?:([+-])=\s*([\d.]+))?$/.exec(pos.trim()); + if (!m) return null; + const name = m[1]!; + let base = labels.get(name); + if (base === undefined) { + base = cursor; // auto-create label at the current end-of-timeline + labels.set(name, base); + } + if (m[2] && m[3]) { + const n = Number.parseFloat(m[3]); + if (Number.isFinite(n)) return m[2] === "+" ? base + n : base - n; + } + return base; +} + +function resolveTimelinePositions( + anims: Omit[], + labelDefs: AddLabelDef[] = [], +): void { let cursor = 0; let prevStart = 0; - for (const anim of anims) { + const labels = new Map(); + // Interleave addLabel definitions with tweens by source order so labels are + // available exactly when later tweens reference them. + let labelIdx = 0; + const sortedLabels = [...labelDefs].sort((a, b) => a.order - b.order); + + const defineLabel = (def: AddLabelDef): void => { + let value: number; + if (typeof def.position === "number") value = def.position; + else if (typeof def.position === "string") { + value = resolveLabelPosition(def.position, labels, cursor) ?? cursor; + } else value = cursor; // no position ⇒ end of timeline + labels.set(def.name, Math.max(0, value)); + }; + + anims.forEach((anim, i) => { + // Apply any addLabel calls authored before this tween. + while (labelIdx < sortedLabels.length && sortedLabels[labelIdx]!.order <= i) { + defineLabel(sortedLabels[labelIdx]!); + labelIdx++; + } + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); let start: number | null; @@ -991,7 +1407,9 @@ function resolveTimelinePositions(anims: Omit[]): void { } else if (typeof anim.position === "number") { start = anim.position; } else if (typeof anim.position === "string") { - start = resolvePositionString(anim.position, cursor, prevStart); + start = + resolveLabelPosition(anim.position, labels, cursor) ?? + resolvePositionString(anim.position, cursor, prevStart); } else { start = cursor; } @@ -1001,7 +1419,56 @@ function resolveTimelinePositions(anims: Omit[]): void { prevStart = anim.resolvedStart; cursor = Math.max(cursor, anim.resolvedStart + duration); } - } + }); + + // Any trailing addLabel calls (define for completeness; no tweens follow). + while (labelIdx < sortedLabels.length) defineLabel(sortedLabels[labelIdx++]!); +} + +/** + * Collect `tl.addLabel(name, position)` calls and compute each one's `order` — + * the count of tween calls that precede it in source order — so positions can + * be interleaved against the sorted animation list in resolveTimelinePositions. + */ +function collectAddLabelDefs( + ast: any, + timelineVar: string, + scope: ScopeBindings, + sortedCalls: TweenCallInfo[], +): AddLabelDef[] { + const callLocs = sortedCalls.map((c) => c.node.callee?.property?.loc?.start); + const defs: AddLabelDef[] = []; + acornWalk.simple(ast, { + // fallow-ignore-next-line complexity + CallExpression(node: any) { + const callee = node.callee; + if ( + callee?.type !== "MemberExpression" || + callee.object?.name !== timelineVar || + callee.property?.name !== "addLabel" + ) + return; + const nameNode = node.arguments?.[0]; + const name = typeof nameNode?.value === "string" ? nameNode.value : undefined; + if (!name) return; + // position may be numeric, a label-relative string, or omitted. + const posVal = resolveNode(node.arguments?.[1], scope); + const position = + typeof posVal === "number" || typeof posVal === "string" ? posVal : undefined; + const labelLoc = callee.property?.loc?.start; + let order = sortedCalls.length; + if (labelLoc) { + order = callLocs.findIndex( + (l) => + l && + (l.line > labelLoc.line || (l.line === labelLoc.line && l.column > labelLoc.column)), + ); + if (order === -1) order = sortedCalls.length; + } + defs.push({ name, position, order }); + }, + }); + return defs; } function compareByLoc(a: TweenCallInfo, b: TweenCallInfo): number { @@ -1114,7 +1581,15 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); applyTimelineDefaults(rawAnims, detection.defaults); - resolveTimelinePositions(rawAnims); + // Seed tween start-keyframes from gsap.set()/tl.set() pre-states (read-only + // enrichment; the write path keeps source untouched for round-trip parity). + seedSetStates(rawAnims, collectGsapSetStates(ast, scope, targetBindings, script)); + const labelDefs = collectAddLabelDefs(ast, timelineVar, scope, calls); + resolveTimelinePositions(rawAnims, labelDefs); + // Honesty pass (read path only): make staggered collection tweens read as + // real per-element motion instead of a flat no-op. Done after positions so + // duration is settled; before ids so the annotation is part of the id. + annotateStaggeredCollections(rawAnims); const animations = assignStableIds(rawAnims); const timelineMatch = script.match( diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2549c21bb9..e5fc55b5d4 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -146,6 +146,27 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); + it("warns loudly when GSAP timelines are registered but none bind (frozen-render contract trap)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Root is MISSING data-composition-id, so the render path cannot resolve a + // timeline even though one is registered → would silently render frozen at t=0. + const root = document.createElement("div"); + root.className = "clip"; + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { main: createMockTimeline(6) }; + + initSandboxRuntimeModular(); + + const warned = warnSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(warned).toContain("[HyperFrames]"); + expect(warned).toContain("none bound"); + }); + it("uses the shorter authored host window when the child timeline is longer", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bdc0e2c3a2..70fe7e26a8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1964,6 +1964,35 @@ export function initSandboxRuntimeModular(): void { const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (boundDuration > 0) { clock.setDuration(boundDuration); + } else { + // No GSAP timeline bound. Legitimate for CSS/WAAPI/Lottie-only comps + // (adapters drive time). But if GSAP timelines ARE registered yet none + // bound, the contract is broken: the render resolves the root's + // data-composition-id and seeks window.__timelines[], so a + // missing/mismatched id (or the loose `window.__timelines = { main: tl }` + // form) leaves the render FROZEN at t=0 even though `--shot` — which seeks + // __timelines directly — looks fine. Surface it loudly, don't fail silently. + const registry = (window as Record).__timelines as + | Record + | undefined; + const registeredKeys = registry ? Object.keys(registry) : []; + if (registeredKeys.length > 0) { + const rootEl = + document.querySelector("[data-composition-id]") ?? document.querySelector(".clip"); + const rootId = rootEl?.getAttribute("data-composition-id") ?? null; + postRuntimeDiagnosticOnce( + "root_timeline_unbound_registry_present", + { rootCompositionId: rootId, registeredTimelineKeys: registeredKeys }, + "root_timeline_unbound_registry_present", + ); + console.warn( + `[HyperFrames] ${registeredKeys.length} GSAP timeline(s) are registered ` + + `(${registeredKeys.map((k) => JSON.stringify(k)).join(", ")}) but none bound for render. ` + + `The render seeks window.__timelines[root data-composition-id]; the root's data-composition-id is ` + + `${rootId === null ? "MISSING" : JSON.stringify(rootId)}. Set data-composition-id on the .clip root ` + + `and register the timeline under that exact key, or the render stays frozen at t=0 (even though --shot looks correct).`, + ); + } } runAdapters("discover", state.currentTime); // __renderReady = timeline binding attempted, safe for deterministic seeking. diff --git a/skills/hyperframes-motion/SKILL.md b/skills/hyperframes-motion/SKILL.md new file mode 100644 index 0000000000..0478026b62 --- /dev/null +++ b/skills/hyperframes-motion/SKILL.md @@ -0,0 +1,277 @@ +--- +name: hyperframes-motion +description: "Design, see, and debug GSAP motion as data in a HyperFrames composition — to a professional (10/10) standard. Run `npx hyperframes motion` to surface every tween's keyframes, then `--shot` to render a true-3D onion-skin of the real element, so you reason about an element's MOTION over time — add/move/remove keyframes, refine a path, trace a shape (logo / glyph / icon), tune a 3D flip/tumble, debug 'why does it move there', reproduce a reference, or critique an animation like a motion designer (spacing, weight, easing, pivots, choreography) before editing. Supports multi-stroke traces (pen-up gaps) for shapes with holes or detached parts. Use whenever the task is about where/when/how something moves or whether motion reads as professional; for authoring new scenes from scratch see hyperframes-animation, for the dev-loop CLI see hyperframes-cli." +--- + +# HyperFrames Motion + +Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — the numbers don't show the _shape_, the timing, or what rotation/scale/3D actually look like. `npx hyperframes motion` surfaces every GSAP tween and its keyframes (with absolute times) as editable data; then `--shot` renders a **true-3D onion-skin of the real element** so you verify the motion by eye — all before you render. + +This is **read-then-edit-source**, not a mutation command — it never changes files. Pair it with `inspect` (layout over the timeline) and `render` to ship. For the composition contract (the single paused timeline, `data-duration`, determinism) see `hyperframes-core`; to author motion from scratch see `hyperframes-animation`. + +The goal isn't "the keyframes are correct" — it's **motion indistinguishable from a professional motion designer's.** That takes a trained eye (what to look at) and a diagnostic habit (how to catch what's wrong). The two sections below — **Think like a motion designer** and **The diagnostic pass** — plus **`references/motion-craft.md`** (the depth: easing values, durations, the amateur-tell catalog, the pivot fix, the 10/10 gate) are how you get there. + +## Think like a motion designer (the eye) + +Motion communicates **physics and intent**. Every choice reads either as "a real thing with mass moved for a reason" or as "a value was linearly interpolated." The high-leverage rules (full values + sources in `references/motion-craft.md`): + +- **Spacing ≠ timing.** Timing is the duration; spacing is how the value is distributed across it (the easing). On the curve, **slope = velocity**. **Even spacing reads robotic** (only machines hold constant velocity); **varied spacing reads alive.** In the onion-shot, evenly-spaced ghosts = linear = wrong unless a steady spin/conveyor is literally the subject. +- **Easing has a job.** ease-**out** for entrances (decelerate to rest — the UI default), ease-**in** for permanent exits, ease-**in-out** for on-screen moves. **Asymmetric easing = weight; symmetric = zero-gravity float (the #1 amateur tell).** `linear`/`none` only for a literal constant-velocity subject. +- **Give it weight.** Floaty is the top tell — fix with asymmetric easing, a decisive landing (cluster frames at the end), and a duration matched to size (a tiny element over 600ms always floats; target 150–250ms). Heavy → strong decel (`power3/expo.out`), little overshoot; light/playful → small overshoot (`back.out`). +- **Anticipation + follow-through.** A big move (>~200ms) earns a tiny counter-move before it; motion shouldn't stop dead — overshoot 5–15% then settle. +- **Choreograph one focal point.** Never ≥3 unrelated elements moving at once. Parent leads, children follow; stagger ≈15% of duration; one secondary motion per primary, not three. +- **Restraint.** Every animated property must orient, give feedback, or express — else cut it. **Match the reference/brief's channels exactly**; invented channels read as _wrong_, not richer. + +## The loop + +1. **Surface** — `npx hyperframes motion [dir|file]` (defaults to `./index.html` + sub-compositions). +2. **Read** the keyframe list against your intent (add `--json` for exact data). +3. **Edit** the `keyframes` / property values in the composition `