// Pure, Node-side geometry + SVG generation for the onion-skin motion shot. // // The headless step (motionShot.ts) only SAMPLES — it seeks the live timeline // and reads each element's projected corners. Everything else (which times to // sample, how to fit/lay them out, and the SVG markup) lives here as pure // functions so it can be unit-tested without a browser. export interface Pt { x: number; y: number; } /** One time-sample of one element: its 4 projected corners, centre, colour, opacity. */ export interface OnionSample { t: number; q: Pt[]; c: Pt; color: string; opacity: number; } export interface OnionElement { selector: string; samples: OnionSample[]; } export type ShotLayout = "path" | "strip"; export interface ShotLayoutOptions { layout: ShotLayout; fit: boolean; width: number; height: number; /** Caption drawn top-left (camera / framing / window info). */ label?: string; } export interface Camera { yaw: number; pitch: number; } const ANGLE_PRESETS: Record = { 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; }