#!/usr/bin/env node /* ============================================================================ compile-choreography.mjs The signature mechanic, made real: compiles a `scroll-choreography.json` (declarative scroll → camera-move schema) into runnable GSAP ScrollTrigger + Lenis code. No build step, no deps — plain Node ESM. Usage: node compile-choreography.mjs [--out scene.js] [--html] node compile-choreography.mjs --example # compile the bundled example node compile-choreography.mjs --html # also emit a runnable demo HTML What it does (mirrors scroll-choreography-compilation.md): 1. Parse + validate the choreography object 2. Emit Lenis smooth-scroll init (forwarded to ScrollTrigger) 3. Per chapter: a pinned ScrollTrigger timeline with layer parallax, title reveal, atmosphere/color morph, and velocity nodes 4. Transitions between chapters 5. A reduced-motion guard that no-ops the timeline The single most important job: map the schema's CSS-style property names (translateX/translateY/rotateZ…) to GSAP's shorthand (x/y/rotation…), because GSAP silently ignores the CSS names. That mapping lives in ONE place below and is the reason this compiler exists. ========================================================================== */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, join, basename } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const INPUTS = ['scroll-progress','scroll-velocity','pointer-x','pointer-y','proximity']; /* ---- the one mapping that matters: schema (CSS) → GSAP shorthand ---------- */ const GSAP_PROP = { translateX: 'x', translateY: 'y', translateZ: 'z', rotateX: 'rotationX', rotateY: 'rotationY', rotateZ: 'rotation', scale: 'scale', opacity: 'opacity', // passthroughs that GSAP accepts as-is: letterSpacing: 'scaleX', backgroundColor: 'backgroundColor', }; const gprop = (p) => GSAP_PROP[p] ?? p; const withUnit = (v, unit) => (unit && typeof v === 'number' ? `${v}${unit}` : v); const propertyValue = (p,v) => p.property === 'letterSpacing' ? 1 + Math.max(-1,Math.min(1,parseFloat(v)||0))*.3 : withUnit(v,p.unit); /* ---- tiny validation (enough to fail loudly, not a full JSON-Schema run) -- */ export function validate(doc) { const errs = []; if (!doc || typeof doc !== 'object') throw new Error('Invalid choreography: root is not an object'); const version=Number.parseInt(doc.metadata?.version||'2',10); if(![2,3].includes(version))errs.push('Only choreography v2 and v3 are supported'); if (!Array.isArray(doc.chapters) || !doc.chapters.length) errs.push('`chapters` must be a non-empty array'); (Array.isArray(doc.chapters)?doc.chapters:[]).forEach((c, i) => { if (!/^[a-z][a-zA-Z0-9_-]*$/.test(c.id || '')) errs.push(`chapters[${i}] has invalid id`); if (!Array.isArray(c.layers) || !c.layers.length) errs.push(`chapters[${i}].layers must be a non-empty array`); for(const layer of c.layers||[]){ if(!/^[a-z][a-zA-Z0-9_-]*$/.test(layer.id||''))errs.push(`chapters[${i}] has invalid layer id`); for(const p of layer.animation?.properties||[])if(version===3&&['letterSpacing','backgroundColor'].includes(p.property))errs.push('v3 prohibits continuous '+p.property+'; use a transform wrapper or opacity background layer'); } if(version===3&&c.titleReveal?.type==='letterSpacingScrub')errs.push('v3 replaces letterSpacingScrub with scaleDownEntrance'); if(version===3)for(const node of c.velocityNodes||[])if(node.above?.letterSpacing!==undefined||node.below?.letterSpacing!==undefined)errs.push('v3 prohibits velocity-driven letterSpacing'); }); for(const binding of doc.signalBindings||[]){ if(!INPUTS.includes(binding.input)||typeof binding.selector!=='string'||!binding.selector||!/^--cinematic-[a-z0-9-]+$/.test(binding.output||''))errs.push('Invalid signal binding input, selector or output'); for(const name of ['from','to','staticValue','radius'])if(binding[name]!==undefined&&!Number.isFinite(binding[name]))errs.push('Signal binding '+name+' must be finite'); if(binding.range&&(!Array.isArray(binding.range)||binding.range.length!==2||!binding.range.every(Number.isFinite)||binding.range[0]>=binding.range[1]))errs.push('Signal binding range must ascend'); if(binding.unit&&!['px','deg','%',''].includes(binding.unit))errs.push('Unsupported signal binding unit'); } if (errs.length) throw new Error('Invalid choreography:\n - ' + errs.join('\n - ')); return doc; } /* GSAP accepts cubic-bezier via CustomEase, but named eases are safer in raw output. Pass cubic-beziers straight through as a string GSAP can register; map a few common ones to named eases for portability. */ function mapEase(e) { if (!e) return undefined; const named = { 'cubic-bezier(0.16, 1, 0.3, 1)': 'power3.out', 'cubic-bezier(0.7, 0, 0.84, 0)': 'power3.in', 'cubic-bezier(0.87, 0, 0.13, 1)': 'power4.inOut', 'cubic-bezier(0.34, 1.56, 0.64, 1)': 'back.out(1.4)', }; return named[e] || e; // GSAP-named eases (power3.out etc.) pass through } /* ---- compile one chapter to a GSAP block ---------------------------------- */ function compileChapter(ch, globals) { const sel = `[data-chapter='${ch.id}']`; const pin = ch.pin || {}; const pinDur = pin.pinDuration ?? 200; // scrub comes from the chapter's first-layer trigger, NOT the Lenis lerp // (globals.scrollSmoothing). Default 0.5 per performance-budget ScrollTrigger.defaults. const firstTrigger = ch.layers?.[0]?.animation?.trigger || {}; const scrub = firstTrigger.scrub ?? true; // anticipatePin: GSAP wants a small numeric hint. Map from pin.anticipatorySettle // (0.0–0.15 fraction); default 1 when unspecified. const anticipatePin = pin.anticipatorySettle ?? 1; const lines = []; lines.push(` /* ── Chapter: ${ch.id} (pattern: ${ch.pattern || 'custom'}) ── */`); lines.push(` {`); lines.push(` const tl = gsap.timeline({`); lines.push(` defaults: { ease: ${q(mapEase(globals.defaultEasing) || 'none')}, duration: ${globals.defaultDuration ?? 1} },`); lines.push(` scrollTrigger: {`); lines.push(` trigger: "${sel}",`); lines.push(` start: "top top",`); lines.push(` end: "+=${pinDur}vh",`); lines.push(` scrub: ${scrub},`); lines.push(` pin: ${pin.enabled !== false},`); lines.push(` pinSpacing: ${pin.pinSpacing !== false},`); lines.push(` anticipatePin: ${anticipatePin},`); lines.push(` fastScrollEnd: ${firstTrigger.fastScrollEnd ?? true},`); lines.push(` invalidateOnRefresh: true,`); lines.push(` },`); lines.push(` });`); // layers → parallax tweens, positioned at 0 so they scrub together (ch.layers || []).forEach((layer) => { const lsel = `${sel} [data-layer='${layer.id}']`; const props = layer.animation?.properties || []; if (!props.length) return; const tween = {}; const fromTween = {}; let hasFrom = false; props.forEach((p) => { const g = gprop(p.property); if(p.property==='backgroundColor')return; tween[g] = propertyValue(p,p.to); if (p.from !== undefined) { fromTween[g] = propertyValue(p,p.from); hasFrom = true; } }); const dur = layer.animation?.duration ?? 1; const easing = mapEase(props[0]?.easing || globals.defaultEasing); const willChange = layer.willChange ? `, willChange: "transform"` : ''; if (hasFrom) { lines.push(` tl.fromTo("${lsel}", ${json(fromTween)}, { ${spread(tween)}, ease: ${q(easing)}, duration: ${dur}${willChange} }, 0);`); } else { lines.push(` tl.to("${lsel}", { ${spread(tween)}, ease: ${q(easing)}, duration: ${dur}${willChange} }, 0);`); } for(const p of props.filter(p=>p.property==='backgroundColor'))lines.push(` tl.to(paintLayer(${q(lsel)}, ${q(p.to)}, ${q(p.from)}), { opacity: 1, ease: "none", duration: ${dur} }, 0);`); }); // title reveal if (ch.titleReveal) { lines.push(...compileTitleReveal(ch.titleReveal, sel, globals)); } // atmosphere / colour morph if (ch.atmosphere?.colorMorph) { const m = ch.atmosphere.colorMorph; lines.push(` tl.to(paintLayer(${q(sel)}, ${q(m.to)}, ${q(m.from)}), { opacity: 1, ease: "none", duration: 1 }, ${m.scrollStart ?? 0});`); } else if (ch.atmosphere?.backgroundColor) { lines.push(` gsap.set("${sel}", { backgroundColor: ${q(ch.atmosphere.backgroundColor)} });`); } // velocity nodes → ScrollTrigger onUpdate reacting to getVelocity() if (Array.isArray(ch.velocityNodes) && ch.velocityNodes.length) { lines.push(...compileVelocity(ch.velocityNodes, sel)); } lines.push(` }`); return lines.join('\n'); } function compileTitleReveal(t, sel, globals) { const tsel = `${sel} [data-title]`; const r = t.scrollRange || { start: 0, end: 0.4 }; const ease = q(mapEase(t.easing || globals.defaultEasing)); const at = r.start ?? 0; const dur = Math.max(0.1, (r.end ?? 0.4) - (r.start ?? 0)); const L = []; L.push(` /* title: ${t.type} */`); switch (t.type) { case 'maskReveal': case 'clipPathWipe': L.push(` tl.fromTo("${tsel}", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", ease: ${ease}, duration: ${dur} }, ${at});`); break; case 'verticalMask': L.push(` tl.fromTo("${tsel}", { clipPath: "inset(100% 0 0 0)" }, { clipPath: "inset(0% 0 0 0)", ease: ${ease}, duration: ${dur} }, ${at});`); break; case 'wordStagger': case 'splitLineRise': L.push(` tl.fromTo("${tsel} .w", { yPercent: 110, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, stagger: ${t.stagger?.offset ?? 0.06}, ease: ${ease}, duration: ${dur} }, ${at});`); break; case 'letterStagger': case 'typewriterReveal': L.push(` tl.fromTo("${tsel} .c", { autoAlpha: 0 }, { autoAlpha: 1, stagger: ${t.stagger?.offset ?? 0.02}, ease: "none", duration: ${dur} }, ${at});`); break; case 'letterSpacingScrub': L.push(` tl.fromTo("${tsel}", { scaleX: 1.12, autoAlpha: 0.4 }, { scaleX: 1, autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`); break; case 'scaleDownEntrance': L.push(` tl.fromTo("${tsel}", { scale: 1.3, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`); break; case 'blurCrossfade': // never animate filter; crossfade two stacked copies (taste-guardrails §1.1) L.push(` tl.fromTo("${tsel} .sharp", { autoAlpha: 0 }, { autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`); L.push(` tl.to("${tsel} .soft", { autoAlpha: 0, ease: ${ease}, duration: ${dur} }, ${at});`); break; default: L.push(` tl.fromTo("${tsel}", { autoAlpha: 0, y: 30 }, { autoAlpha: 1, y: 0, ease: ${ease}, duration: ${dur} }, ${at});`); } return L; } function compileVelocity(nodes, sel) { const tsel = `${sel} [data-title]`; const L = []; L.push(` /* velocity-reactive typography */`); L.push(` ScrollTrigger.create({`); L.push(` trigger: "${sel}", start: "top bottom", end: "bottom top",`); L.push(` onUpdate: (self) => {`); L.push(` const v = Math.abs(self.getVelocity()) / 1000;`); nodes.forEach((n) => { const cmp = n.comparison === 'below' ? '<' : '>'; const s = n.above || {}; const lerp = n.lerpFactor ?? 0.1; const set = Object.entries(s).map(([k, val]) => `${gprop(k)}: ${q(propertyValue({property:k},val))}`).join(', '); const base = n.below || {}; const reset = Object.entries(base).map(([k, val]) => `${gprop(k)}: ${q(propertyValue({property:k},val))}`).join(', '); L.push(` if (v ${cmp} ${n.threshold}) { gsap.to("${tsel}", { ${set}, duration: ${lerp}, overwrite: "auto" }); }`); if (reset) L.push(` else { gsap.to("${tsel}", { ${reset}, duration: ${lerp}, overwrite: "auto" }); }`); }); L.push(` },`); L.push(` });`); return L; } function compileTransition(t) { const TYPE = { craneShot: { y: -100, rotationX: 4 }, whipPan: { x: '-100vw' }, matchCut: { autoAlpha: 0 }, dissolve: { autoAlpha: 0, scale: 0.97 }, pushIn: { scale: 1.08 }, hardCut: {}, }; const move = TYPE[t.type] || {}; const ease = q(mapEase(t.easing || 'power4.inOut')); const set = Object.entries(move).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', '); if (t.type === 'hardCut' || !set) { return ` /* transition ${t.from} → ${t.to}: hard cut (no tween) */`; } return [ ` /* transition ${t.from} → ${t.to}: ${t.type} */`, ` gsap.timeline({ scrollTrigger: { trigger: "[data-chapter='${t.to}']", start: "top bottom", end: "top top", scrub: true } })`, ` .to("[data-chapter='${t.from}']", { ${set}, ease: ${ease} }, 0);`, ].join('\n'); } /* ---- helpers -------------------------------------------------------------- */ const q = (s) => (s === undefined ? 'undefined' : JSON.stringify(s)); const json = (o) => JSON.stringify(o); const spread = (o) => Object.entries(o).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', '); /* ---- top-level emit ------------------------------------------------------- */ export function compile(doc) { validate(doc); const g = doc.globals || {}; const out = []; out.push(`/* AUTO-GENERATED by compile-choreography.mjs — do not edit by hand. */`); if(!doc.metadata?.version?.startsWith('3.'))out.push(`/* DEPRECATED v2 input: spacing effects become scaleX; background morphs use opacity layers. */`); out.push(`/* Source choreography: ${doc.metadata?.name || 'unnamed'} */`); out.push(`import { gsap } from "gsap";`); out.push(`import { ScrollTrigger } from "gsap/ScrollTrigger";`); out.push(`import Lenis from "lenis";`); if(doc.signalBindings?.length){out.push(`import { createCinematicRuntime } from "./runtime/cinematic.mjs";`);out.push(`import { mountSignalBindings } from "./runtime/choreography.mjs";`);} out.push(`gsap.registerPlugin(ScrollTrigger);`); out.push(``); out.push(`export function initChoreography(root = document.documentElement, { lenis: sharedLenis = null } = {}) {`); if(doc.signalBindings?.length){ out.push(` let runtime, attached = false;`); out.push(` const tick = () => { if (!runtime.tick(performance.now())) { gsap.ticker.remove(tick); attached = false; } };`); out.push(` const wake = () => { if (runtime && !attached) { attached = true; gsap.ticker.add(tick); } };`); out.push(` runtime = createCinematicRuntime(root, { clock: "external", onWake: wake });`); out.push(` let unbind; try { unbind = mountSignalBindings(root, runtime, ${json(doc.signalBindings)}); runtime.wake(); } catch (error) { gsap.ticker.remove(tick); runtime.dispose(); throw error; }`); } out.push(` const mm = gsap.matchMedia(root);`); out.push(` mm.add("(prefers-reduced-motion: no-preference) and (min-width: 768px)", () => {`); out.push(` /* Scoped triggers revert when preferences change. Mobile stays in flow. */`); out.push(` const paintNodes = [];`); out.push(` function paintLayer(selector, color, from) { return Array.from(root.querySelectorAll(selector), host => {`); out.push(` if (from !== undefined) gsap.set(host, {backgroundColor:from});`); out.push(` gsap.set(host, { isolation: "isolate", ...(getComputedStyle(host).position === "static" ? {position:"relative"} : {}) });`); out.push(` const layer = document.createElement("span"); layer.setAttribute("aria-hidden","true");`); out.push(` Object.assign(layer.style, {position:"absolute",inset:"0",zIndex:"-1",pointerEvents:"none",background:color,opacity:"0",borderRadius:"inherit"});`); out.push(` host.append(layer); paintNodes.push(layer); return layer; }); }`); out.push(` const ownedLenis = ${Boolean(g.scrollSmoothing)} && !sharedLenis;`); out.push(` const lenis = sharedLenis || (ownedLenis ? new Lenis({ lerp: ${g.scrollSmoothing ?? 0.1} }) : null);`); out.push(` const onScroll = () => ScrollTrigger.update();`); out.push(` const frame = (time) => lenis?.raf(time * 1000);`); out.push(` lenis?.on("scroll", onScroll);`); out.push(` if (ownedLenis) gsap.ticker.add(frame);`); out.push(``); (doc.chapters || []).forEach((ch) => out.push(compileChapter(ch, g))); if (Array.isArray(doc.transitions)) { out.push(``); doc.transitions.forEach((t) => out.push(compileTransition(t))); } out.push(``); out.push(` ScrollTrigger.refresh();`); out.push(` return () => {`); out.push(` if (ownedLenis) gsap.ticker.remove(frame);`); out.push(` lenis?.off("scroll", onScroll);`); out.push(` if (ownedLenis) lenis.destroy();`); out.push(` paintNodes.forEach(node => node.remove());`); out.push(` };`); out.push(` });`); out.push(doc.signalBindings?.length?` return () => { mm.revert(); unbind(); gsap.ticker.remove(tick); runtime.dispose(); };`:` return () => mm.revert();`); out.push(`}`); return out.join('\n'); } /* ============================================================================ VIDEO TARGET — one choreography, two media. The same document that compiles to a scroll-driven page (above) compiles to a fixed-time paused GSAP timeline for video renderers (HyperFrames, Remotion). Time mapping (FRAME.md §5 pacing rules): • scroll pace: PACE seconds per 100vh of pinDuration (default 1.2 — taste-guardrails §3.1), then clamped to [4s, 14s] scene dwell. • in-chapter scroll fractions (titleReveal.scrollRange etc.) multiply the scene's duration and offset from the scene start. Dropped on purpose: Lenis/ScrollTrigger (no scroll), velocity nodes (no scroll velocity in fixed time), reduced-motion guard (a render is a film). The DOM contract is unchanged: [data-chapter='id'] scenes containing [data-layer='id'] and [data-title] — one HTML skeleton serves both targets. ========================================================================== */ const clampN = (v, a, b) => Math.min(b, Math.max(a, v)); function sceneSeconds(ch, pace) { const vh = ch.pin?.pinDuration ?? 200; return clampN((vh / 100) * pace, 4, 14); } function videoChapter(ch, globals, t0, dur) { const sel = `[data-chapter='${ch.id}']`; const L = []; L.push(` /* ── Scene: ${ch.id} — ${t0.toFixed(1)}s → ${(t0 + dur).toFixed(1)}s (pattern: ${ch.pattern || 'custom'}) ── */`); // scene enter / exit (the "cut") L.push(` tl.fromTo("${sel}", { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.6, ease: "power3.out" }, ${t0.toFixed(2)});`); L.push(` tl.to("${sel}", { autoAlpha: 0, duration: 0.5, ease: "power3.in" }, ${(t0 + dur - 0.5).toFixed(2)});`); // layers: scroll parallax becomes a timed drift across the scene (ch.layers || []).forEach((layer) => { const lsel = `${sel} [data-layer='${layer.id}']`; const props = layer.animation?.properties || []; if (!props.length) return; const tween = {}; const fromTween = {}; let hasFrom = false; props.forEach((p) => { const gp = gprop(p.property); tween[gp] = propertyValue(p,p.to); if (p.from !== undefined) { fromTween[gp] = propertyValue(p,p.from); hasFrom = true; } }); const ease = mapEase(props[0]?.easing || globals.defaultEasing) || 'none'; const at = (t0 + dur * 0.05).toFixed(2); const d = (dur * 0.9).toFixed(2); if (hasFrom) L.push(` tl.fromTo("${lsel}", ${json(fromTween)}, { ${spread(tween)}, ease: ${q(ease)}, duration: ${d} }, ${at});`); else L.push(` tl.to("${lsel}", { ${spread(tween)}, ease: ${q(ease)}, duration: ${d} }, ${at});`); }); // title reveal: scroll fractions → seconds within the scene if (ch.titleReveal) { const t = ch.titleReveal; const r = t.scrollRange || { start: 0.08, end: 0.45 }; const at = (t0 + dur * (r.start ?? 0.08)).toFixed(2); const d = Math.max(0.4, dur * ((r.end ?? 0.45) - (r.start ?? 0.08))).toFixed(2); const ease = q(mapEase(t.easing || globals.defaultEasing) || 'power3.out'); const tsel = `${sel} [data-title]`; switch (t.type) { case 'wordStagger': case 'splitLineRise': L.push(` tl.fromTo("${tsel} .w", { yPercent: 110, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, stagger: ${t.stagger?.offset ?? 0.08}, ease: ${ease}, duration: ${d} }, ${at});`); break; case 'letterSpacingScrub': L.push(` tl.fromTo("${tsel}", { letterSpacing: "0.4em", autoAlpha: 0.4 }, { letterSpacing: "0em", autoAlpha: 1, ease: ${ease}, duration: ${d} }, ${at});`); break; case 'maskReveal': case 'clipPathWipe': L.push(` tl.fromTo("${tsel}", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", ease: ${ease}, duration: ${d} }, ${at});`); break; case 'verticalMask': L.push(` tl.fromTo("${tsel}", { clipPath: "inset(100% 0 0 0)" }, { clipPath: "inset(0% 0 0 0)", ease: ${ease}, duration: ${d} }, ${at});`); break; case 'scaleDownEntrance': L.push(` tl.fromTo("${tsel}", { scale: 1.3, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, ease: ${ease}, duration: ${d} }, ${at});`); break; default: L.push(` tl.fromTo("${tsel}", { y: 30, autoAlpha: 0 }, { y: 0, autoAlpha: 1, ease: ${ease}, duration: ${d} }, ${at});`); } } // atmosphere: colour morph becomes a timed background tween on the stage if (ch.atmosphere?.colorMorph) { const m = ch.atmosphere.colorMorph; L.push(` tl.to("#stage, ${sel}", { backgroundColor: ${q(m.to)}, ease: "none", duration: ${(dur * 0.5).toFixed(2)} }, ${(t0 + dur * (m.scrollStart ?? 0.2)).toFixed(2)});`); } else if (ch.atmosphere?.backgroundColor) { L.push(` tl.set("${sel}", { backgroundColor: ${q(ch.atmosphere.backgroundColor)} }, ${t0.toFixed(2)});`); } if (Array.isArray(ch.velocityNodes) && ch.velocityNodes.length) { L.push(` /* velocityNodes skipped — scroll velocity does not exist in fixed-time video */`); } return L.join('\n'); } function compileVideo(doc, { pace = 1.2 } = {}) { validate(doc); const g = doc.globals || {}; const id = doc.metadata?.id || 'main'; const chapters = doc.chapters || []; // scene schedule: sequential, durations from pacing rules let t = 0; const schedule = chapters.map((ch) => { const dur = sceneSeconds(ch, pace); const entry = { ch, t0: t, dur }; t += dur; return entry; }); const total = Math.ceil(t * 10) / 10; const out = []; out.push(`/* AUTO-GENERATED by compile-choreography.mjs --target video — do not edit by hand. */`); out.push(`/* Source choreography: ${doc.metadata?.name || 'unnamed'} · ${chapters.length} scenes · ${total}s total */`); out.push(`/* Dual-use output:`); out.push(` • HyperFrames: load via
${hyperframesSkeleton(doc)}
`; } function compileHarness(doc, { pace = 1.2 } = {}) { const timelineCode = compileVideo(doc, { pace }) .replace(/^export const CHOREOGRAPHY_DURATION/m, 'const CHOREOGRAPHY_DURATION') .replace(/^export function buildChoreographyTimeline/m, 'function buildChoreographyTimeline'); const name = doc.metadata?.name || 'choreography'; return ` ${name} — choreography preview
${harnessSkeleton(doc, pace)}
0.0s / 0.0s
`; } /* ---- CLI ------------------------------------------------------------------ */ function main() { const args = process.argv.slice(2); let src = args.find((a) => !a.startsWith('--')); if (args.includes('--example') || !src) { src = join(__dirname, 'scroll-choreography.json'); } if (!existsSync(src)) { console.error(`✗ not found: ${src}`); process.exit(1); } let doc = JSON.parse(readFileSync(src, 'utf8')); // a schema file stores its real choreography under examples[0] if (doc.$schema && Array.isArray(doc.examples) && doc.examples.length) { console.error(`note: ${basename(src)} is a schema — compiling examples[0] ("${doc.examples[0].metadata?.name || 'example'}")`); doc = doc.examples[0]; } const tArg = args.indexOf('--target'); const target = tArg >= 0 ? args[tArg + 1] : 'web'; const pArg = args.indexOf('--pace'); const pace = pArg >= 0 ? parseFloat(args[pArg + 1]) : 1.2; let code; if (args.includes('--harness')) code = compileHarness(doc, { pace }); else if (target === 'video') code = compileVideo(doc, { pace }); else if (target === 'hyperframes') code = compileHyperframes(doc, { pace }); else if (target === 'web') code = compile(doc); else { console.error(`✗ unknown --target "${target}" (use: web | video | hyperframes, or --harness for a preview HTML)`); process.exit(1); } const label = args.includes('--harness') ? 'harness' : target; const outArg = args.indexOf('--out'); const outPath = outArg >= 0 ? args[outArg + 1] : null; if (outPath) { writeFileSync(outPath, code); console.error(`✓ [${label}] wrote ${outPath} (${code.split('\n').length} lines)`); } else { process.stdout.write(code + '\n'); } } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main();