paper-motion 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +59 -2
  2. package/bin/pamo.mjs +664 -0
  3. package/examples/breath/brief.md +11 -0
  4. package/examples/breath/poster.jpg +0 -0
  5. package/examples/breath/scene.html +59 -0
  6. package/examples/chrome-endcard/brief.md +7 -0
  7. package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
  8. package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
  9. package/examples/chrome-endcard/poster.jpg +0 -0
  10. package/examples/chrome-endcard/scene.html +68 -0
  11. package/examples/chrome-endcard/wordmark.js +16 -0
  12. package/examples/halo-explainer/audio/script.txt +8 -0
  13. package/examples/halo-explainer/audio/words.json +554 -0
  14. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  15. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  16. package/examples/halo-explainer/brand/motion.json +9 -0
  17. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  18. package/examples/halo-explainer/brand/sound.json +33 -0
  19. package/examples/halo-explainer/brand/tokens.json +97 -0
  20. package/examples/halo-explainer/brief.md +34 -0
  21. package/examples/halo-explainer/poster.jpg +0 -0
  22. package/examples/halo-explainer/scene.html +372 -0
  23. package/examples/install-loop/brief.md +7 -0
  24. package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
  25. package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
  26. package/examples/install-loop/poster.jpg +0 -0
  27. package/examples/install-loop/scene.html +46 -0
  28. package/examples/orbit-keynote/audio/script.txt +7 -0
  29. package/examples/orbit-keynote/audio/words.json +494 -0
  30. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  31. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  32. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  33. package/examples/orbit-keynote/brand/motion.json +9 -0
  34. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  35. package/examples/orbit-keynote/brand/sound.json +45 -0
  36. package/examples/orbit-keynote/brand/tokens.json +106 -0
  37. package/examples/orbit-keynote/brief.md +25 -0
  38. package/examples/orbit-keynote/poster.jpg +0 -0
  39. package/examples/orbit-keynote/scene.html +323 -0
  40. package/examples/signal/brief.md +10 -0
  41. package/examples/signal/poster.jpg +0 -0
  42. package/examples/signal/scene.html +50 -0
  43. package/lib/board.mjs +95 -0
  44. package/lib/browser.mjs +88 -0
  45. package/lib/catalog.mjs +77 -0
  46. package/lib/customize.mjs +162 -0
  47. package/lib/keys.mjs +93 -0
  48. package/lib/live-board.js +128 -0
  49. package/lib/modules.mjs +23 -0
  50. package/lib/react-hooks.mjs +9 -0
  51. package/lib/registry.mjs +344 -0
  52. package/lib/render.mjs +193 -0
  53. package/lib/server.mjs +89 -0
  54. package/lib/sfx.mjs +287 -0
  55. package/lib/shaderset.mjs +65 -0
  56. package/lib/wordmark.mjs +64 -0
  57. package/package.json +46 -4
  58. package/runtime/brand.js +71 -0
  59. package/runtime/core.js +269 -0
  60. package/runtime/index.js +349 -0
  61. package/runtime/jsx-shim.js +3 -0
  62. package/runtime/kit.js +98 -0
  63. package/runtime/moves/bar-reveal.js +19 -0
  64. package/runtime/moves/blur-in.js +7 -0
  65. package/runtime/moves/cells.js +17 -0
  66. package/runtime/moves/crossfade.js +6 -0
  67. package/runtime/moves/cut.js +3 -0
  68. package/runtime/moves/dive-in.js +21 -0
  69. package/runtime/moves/draw-on.js +12 -0
  70. package/runtime/moves/drift.js +7 -0
  71. package/runtime/moves/flash.js +15 -0
  72. package/runtime/moves/flip-3d.js +9 -0
  73. package/runtime/moves/flip-in.js +12 -0
  74. package/runtime/moves/flip-lift.js +24 -0
  75. package/runtime/moves/glitch.js +16 -0
  76. package/runtime/moves/iris.js +12 -0
  77. package/runtime/moves/magic-move.js +49 -0
  78. package/runtime/moves/mask-reveal.js +10 -0
  79. package/runtime/moves/odometer.js +32 -0
  80. package/runtime/moves/orbit.js +8 -0
  81. package/runtime/moves/pop-in.js +12 -0
  82. package/runtime/moves/punch.js +9 -0
  83. package/runtime/moves/push.js +14 -0
  84. package/runtime/moves/rgb-split.js +7 -0
  85. package/runtime/moves/roll.js +20 -0
  86. package/runtime/moves/scan-reveal.js +10 -0
  87. package/runtime/moves/scramble.js +21 -0
  88. package/runtime/moves/shake.js +15 -0
  89. package/runtime/moves/slices.js +14 -0
  90. package/runtime/moves/snap-scroll.js +23 -0
  91. package/runtime/moves/squash-drop.js +21 -0
  92. package/runtime/moves/tile-swirl.js +29 -0
  93. package/runtime/moves/track-in.js +7 -0
  94. package/runtime/moves/type-on.js +41 -0
  95. package/runtime/moves/unfold.js +20 -0
  96. package/runtime/moves/whip-pan.js +21 -0
  97. package/runtime/moves/whip.js +14 -0
  98. package/runtime/moves/wipe.js +9 -0
  99. package/runtime/moves/word-rise.js +10 -0
  100. package/runtime/moves/zoom-through.js +13 -0
  101. package/runtime/moves.js +94 -0
  102. package/runtime/player.js +69 -0
  103. package/runtime/react-shim.js +15 -0
  104. package/templates/blank/brief.md +39 -0
  105. package/templates/blank/scene.html +44 -0
  106. package/templates/brand-sheet/index.html +241 -0
  107. package/templates/move-demo/brief.md +4 -0
  108. package/templates/move-demo/scene.html +158 -0
  109. package/templates/piece/brief.md +39 -0
  110. package/templates/piece/scene.html +62 -0
  111. package/templates/shader-set/shot.js +47 -0
  112. package/templates/still/brief.md +3 -0
  113. package/templates/still/scene.html +56 -0
package/bin/pamo.mjs ADDED
@@ -0,0 +1,664 @@
1
+ #!/usr/bin/env node
2
+ // paper-motion CLI — scaffold, preview, stills and frame-exact renders of Paper Shader scenes.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync, spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ // The repo's production skill, when the engine runs from a paper-motion checkout (for `shaders --md`).
10
+ const REPO_SKILL = path.resolve(ENGINE, '..', '..', 'skills', 'make-video');
11
+
12
+ const HELP = `paper-motion — minimal, creative-directed video from code + Paper Shaders
13
+
14
+ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/engine/bin/pamo.mjs in the repo)
15
+
16
+ setup install engine deps, check ffmpeg + browser
17
+ doctor only check the environment
18
+ new <name> [--dir works] [--format 16:9|9:16|1:1 | --size 1920x1080] [--fps 30] [--duration 10]
19
+ [--from <example> | --piece (wired to brand/) | --still |
20
+ --move <wipe|push|…> [--pace calm|medium|fast] [--text typing|rise|blur|track|plain] [--camera still|drift|travel]
21
+ [--sound none|soft|crisp|airy|ambient]]
22
+ create works/<name>/scene.html (+ brief.md)
23
+ examples list bundled example scenes
24
+ preview <scene.html> [--port 4317] [--no-open]
25
+ live-reloading preview with a scrubber
26
+ still <scene.html> [--t 0,2.5,5 | --count 6] [--out dir | --out file.png] [--dpr 1] [--motion-blur 8]
27
+ PNG stills + contact-sheet.jpg (for critique);
28
+ with one --t and --out file.png, writes just that file
29
+ render <scene.html> [--out file] [--format mp4|hevc|prores|webm|gif|png]
30
+ [--from s] [--to s] [--fps n] [--crf n] [--dpr 1|2] [--workers n] [--audio file]
31
+ [--gif-width 720] [--gif-fps 24] [--no-sfx] [--motion-blur 8] [--shutter 180] [--loudness -16]
32
+ frame-exact render via headless Chromium + ffmpeg
33
+ (sound effects declared with s.sfx() are mixed in)
34
+ sfx <scene.html> [--out file.wav] [--from s] [--to s]
35
+ export only the synthesized sound effects
36
+ board --url --title "…" [--ask "…"] [--reply name] [--look palette,font]
37
+ --option id[:key=value,…] [--note "why"] … [--kit] [--site url]
38
+ a live board: a paper-motion.app link the user opens to see
39
+ 2–4 options in their look, tune one and choose; with --kit each
40
+ option is a space-separated list of ids (a whole kit)
41
+ board <a.html> <b.html> [...] [--t 2 | --clip 5] [--labels "A · calm|B · loud"] [--cols n] [--motion-blur 6] [--out board.png]
42
+ option board for interviews: 2–6 options side by side,
43
+ lettered A, B, C…; a still (.png) or short clips (.mp4).
44
+ Labels split on | (or on commas when there is no |).
45
+ --cols 1 stacks options full width (judge type at real size)
46
+ --sequence plays the clips one after another, with their sound
47
+ (for choices you hear: sound palettes, typing)
48
+ wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking -0.02em]
49
+ [--color #fff] [--size 400] [--pad 0] [--out wordmark.png]
50
+ the name set in its typeface, as a transparent PNG
51
+ cropped to the ink (--pad adds margin, × size)
52
+ sheet [--brand brand] [--fresh] [--serve [--port 4317]] [--width 1440] [--no-pdf]
53
+ the brand sheet: copies the template to brand/sheet/ (once;
54
+ --fresh replaces it), then exports sheet.png and sheet.pdf,
55
+ or serves it live with --serve
56
+ keys <recording> [--out brand/sound/keys] [--min-gap 0.055] [--sensitivity 1]
57
+ split a recording of real typing into single keystrokes;
58
+ then s.sfx(t, 'key', { bank: 'brand/sound/keys' })
59
+ sound-samples [--brand brand] renders each cue of brand/sound.json (and a typing sample) to
60
+ brand/sound/*.wav + sounds.json, for the brand sheet
61
+ shader-set <build|boards|render> [--brand brand] [--only 3,7 | ids] [--t 2] [--motion-blur 4]
62
+ the brand's shader set from brand/shader-set/set.json:
63
+ build writes one scene per entry and review boards;
64
+ boards renders them to PNG (12 per board, numbered);
65
+ render writes each loop to renders/ and records it in set.json
66
+ status [--brand brand] what the brand folder has, and the next step on the main flow
67
+ shaders [--json | --md [--out f]] [name]
68
+ catalog of every Paper shader, params and presets
69
+
70
+ Resources (paper-motion.app: moves, backgrounds, palettes, font pairs, recipes)
71
+ add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--registry url|dir]
72
+ install resources into this project: moves to moves/,
73
+ backgrounds to brand/shader-set/set.json, palettes and fonts
74
+ into brand/tokens.json, recipes to pieces/<name>/;
75
+ --set writes your values in (id:key=value with several ids)
76
+ list [type] [--json] every resource (types: component transition text camera
77
+ background palette font-pair recipe)
78
+ search <words> [--type t] [--mode light|dark] [--mood m] [--motion still|gentle|lively]
79
+ [--role r] [--energy 2-4] [--near "#hex,#hex"] [--json]
80
+ find resources by words, mood, mode, energy; --near sorts by color fit
81
+ registry build [--out dir] [--site url]
82
+ validate registry/ and publish registry.json, r/<id>.json, llms.txt
83
+ `;
84
+
85
+ function parse(argv) {
86
+ const out = { _: [] };
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const a = argv[i];
89
+ if (a.startsWith('--')) {
90
+ const eq = a.indexOf('=');
91
+ const k = eq < 0 ? a.slice(2) : a.slice(2, eq), v = eq < 0 ? undefined : a.slice(eq + 1);
92
+ if (v !== undefined) out[k] = v;
93
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--')) out[k] = argv[++i];
94
+ else out[k] = true;
95
+ } else out._.push(a);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ const args = parse(process.argv.slice(2));
101
+ const cmd = args._[0];
102
+
103
+ async function depsReady() {
104
+ const { moduleDir } = await import('../lib/modules.mjs');
105
+ return ['@paper-design/shaders', '@paper-design/shaders-react', 'playwright-core'].every((m) => moduleDir(m));
106
+ }
107
+
108
+ async function ensureDeps() {
109
+ if (await depsReady()) return;
110
+ console.log('Installing engine dependencies (first run)…');
111
+ const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--legacy-peer-deps'], { cwd: ENGINE, stdio: 'inherit', shell: process.platform === 'win32' });
112
+ if (r.status !== 0) { console.error('npm install failed in', ENGINE); process.exit(1); }
113
+ }
114
+
115
+ async function doctor() {
116
+ let ok = true;
117
+ const v = Number(process.versions.node.split('.')[0]);
118
+ console.log(`${v >= 18 ? '✓' : '✗'} node ${process.versions.node}${v >= 18 ? '' : ' (need >= 18.17)'}`); ok &&= v >= 18;
119
+ const ff = spawnSync(process.env.PAPER_MOTION_FFMPEG || 'ffmpeg', ['-version'], { encoding: 'utf8' });
120
+ if (ff.status === 0) {
121
+ const line = ff.stdout.split('\n')[0];
122
+ const hasX265 = /--enable-libx265/.test(ff.stdout);
123
+ console.log(`✓ ${line}${hasX265 ? '' : ' (no libx265: --format hevc unavailable)'}`);
124
+ } else { ok = false; console.log('✗ ffmpeg not found — macOS: brew install ffmpeg · Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg'); }
125
+ const deps = await depsReady();
126
+ console.log(`${deps ? '✓' : '✗'} engine dependencies${deps ? '' : ' — run: setup'}`); ok &&= deps;
127
+ if (deps) {
128
+ const { findBrowser } = await import('../lib/browser.mjs');
129
+ const b = findBrowser();
130
+ console.log(`${b ? '✓' : '✗'} browser ${b ?? '— install Google Chrome or run: npx playwright-core install chromium'}`); ok &&= !!b;
131
+ }
132
+ return ok;
133
+ }
134
+
135
+ function sceneUrl(file, server) {
136
+ const abs = path.resolve(file);
137
+ if (!fs.existsSync(abs)) { console.error(`Scene not found: ${file}`); process.exit(1); }
138
+ const root = rootFor(abs);
139
+ const rel = path.relative(root, abs).split(path.sep).map(encodeURIComponent).join('/');
140
+ return { root, url: (q = '') => `http://127.0.0.1:${server.port}/${rel}${q}` };
141
+ }
142
+ // Serve from the cwd when the scene lives inside it (so ../assets work), else from the scene folder.
143
+ function rootFor(abs) {
144
+ const cwd = process.cwd();
145
+ return abs.startsWith(cwd + path.sep) ? cwd : path.dirname(abs);
146
+ }
147
+
148
+ async function withServer(file, fn, { watch = false, port = 0 } = {}) {
149
+ const { startServer } = await import('../lib/server.mjs');
150
+ const abs = path.resolve(file);
151
+ const server = await startServer({ root: rootFor(abs), port, watch });
152
+ const { url } = sceneUrl(file, server);
153
+ try { return await fn(url, server); } finally { if (!watch) await server.close(); }
154
+ }
155
+
156
+ const workDir = (file) => path.dirname(path.resolve(file));
157
+ const fmtTime = (s) => (s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`);
158
+
159
+ async function main() {
160
+ switch (cmd) {
161
+ case 'setup': {
162
+ await ensureDeps();
163
+ const ok = await doctor();
164
+ if (!ok) process.exit(1);
165
+ console.log('\nReady.');
166
+ break;
167
+ }
168
+ case 'doctor': { process.exit((await doctor()) ? 0 : 1); break; }
169
+
170
+ case 'examples': {
171
+ const dir = path.join(ENGINE, 'examples');
172
+ for (const d of fs.readdirSync(dir)) {
173
+ const brief = path.join(dir, d, 'brief.md');
174
+ const first = fs.existsSync(brief) ? fs.readFileSync(brief, 'utf8').split('\n').find((l) => l.trim() && !l.startsWith('#')) : '';
175
+ console.log(`${d.padEnd(22)} ${first ?? ''}`);
176
+ }
177
+ console.log(`\nfolder: ${dir}\ncopy one: new <name> --from <example>`);
178
+ break;
179
+ }
180
+
181
+ case 'new': {
182
+ const name = args._[1];
183
+ if (!name) { console.error('Usage: new <name>'); process.exit(1); }
184
+ const dest = path.resolve(args.dir ?? 'works', name);
185
+ if (fs.existsSync(dest)) { console.error(`Already exists: ${dest}`); process.exit(1); }
186
+ const src = args.from ? path.join(ENGINE, 'examples', args.from) : path.join(ENGINE, 'templates', args.still ? 'still' : args.move ? 'move-demo' : args.piece ? 'piece' : 'blank');
187
+ if (!fs.existsSync(src)) { console.error(`No example "${args.from}". Run: examples`); process.exit(1); }
188
+ fs.cpSync(src, dest, { recursive: true });
189
+ if (args.still || args.move) fs.rmSync(path.join(dest, 'brief.md'), { force: true }); // options for a board need no brief
190
+ const scene = path.join(dest, 'scene.html');
191
+ let html = fs.readFileSync(scene, 'utf8');
192
+ const FORMATS = { '16:9': '1920x1080', '9:16': '1080x1920', '1:1': '1080x1080', '4:5': '1080x1350' };
193
+ const size = args.size ?? (args.format ? FORMATS[String(args.format)] : null);
194
+ if (args.format && !FORMATS[String(args.format)]) { console.error(`Unknown --format ${args.format}: use ${Object.keys(FORMATS).join(', ')}`); process.exit(1); }
195
+ if (size) { const [w, h] = String(size).split('x').map(Number); html = html.replace(/const W = \d+, H = \d+/, `const W = ${w}, H = ${h}`).replace(/width:\s*\d+/, `width: ${w}`).replace(/height:\s*\d+/, `height: ${h}`); }
196
+ if (args.fps) html = html.replace(/fps:\s*\d+/, `fps: ${Number(args.fps)}`);
197
+ if (args.duration) html = html.replace(/duration:\s*[\d.]+/, `duration: ${Number(args.duration)}`);
198
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`);
199
+ if (args.move) for (const [k, v] of [['MOVE', args.move], ['PACE', args.pace], ['TEXT', args.text], ['CAMERA', args.camera], ['SOUND', args.sound]]) if (typeof v === 'string') html = html.replace(new RegExp(`const ${k} = '[^']*'`), `const ${k} = '${v}'`);
200
+ fs.writeFileSync(scene, html);
201
+ console.log(`Created ${path.relative(process.cwd(), dest)}/\n scene.html ← ${args.still || args.move ? 'an option for a board' : 'the piece (pure function of time)'}${fs.existsSync(path.join(dest, 'brief.md')) ? '\n brief.md ← the creative direction, kept in sync with the user' : ''}`);
202
+ break;
203
+ }
204
+
205
+ case 'preview': {
206
+ await ensureDeps();
207
+ const file = args._[1];
208
+ if (!file) { console.error('Usage: preview <scene.html>'); process.exit(1); }
209
+ const port = Number(args.port ?? 4317);
210
+ const { startServer } = await import('../lib/server.mjs');
211
+ const abs = path.resolve(file);
212
+ let server;
213
+ try { server = await startServer({ root: rootFor(abs), port, watch: true }); }
214
+ catch { server = await startServer({ root: rootFor(abs), port: 0, watch: true }); }
215
+ const { url } = sceneUrl(file, server);
216
+ console.log(`Preview: ${url()}\n(space play/pause · ←/→ frame · shift+←/→ 1s · L loop · H hide UI) — live-reloads on save. Ctrl+C to stop.`);
217
+ if (!args['no-open']) {
218
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
219
+ spawn(opener, [url()], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
220
+ }
221
+ break;
222
+ }
223
+
224
+ case 'still':
225
+ case 'stills': {
226
+ await ensureDeps();
227
+ const file = args._[1];
228
+ if (!file) { console.error('Usage: still <scene.html> [--t 0,1.5] [--count 6]'); process.exit(1); }
229
+ const { renderStills } = await import('../lib/render.mjs');
230
+ const times = args.t !== undefined ? String(args.t).split(',').map(Number) : null;
231
+ const toFile = typeof args.out === 'string' && /\.png$/i.test(args.out);
232
+ if (toFile && times?.length !== 1) { console.error('still: --out file.png needs exactly one --t'); process.exit(1); }
233
+ const os = await import('node:os');
234
+ const outDir = toFile ? fs.mkdtempSync(path.join(os.tmpdir(), 'pm-still-')) : path.resolve(args.out ?? path.join(workDir(file), 'stills'));
235
+ const r = await withServer(file, (url) => renderStills({ url: url('?render'), times, count: args.count ? Number(args.count) : undefined, outDir, dpr: Number(args.dpr ?? 1), sheet: !args['no-sheet'], motionBlur: args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1, shutter: args.shutter ? Number(args.shutter) : 180 }));
236
+ if (toFile) { const dest = path.resolve(args.out); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(r.files[0], dest); fs.rmSync(outDir, { recursive: true, force: true }); r.files = [dest]; }
237
+ for (const f of r.files) console.log(path.relative(process.cwd(), f));
238
+ if (r.sheet) console.log(`contact sheet: ${path.relative(process.cwd(), r.sheet)}`);
239
+ break;
240
+ }
241
+
242
+ case 'render': {
243
+ await ensureDeps();
244
+ const file = args._[1];
245
+ if (!file) { console.error('Usage: render <scene.html> [--out file] [--format mp4]'); process.exit(1); }
246
+ const { renderVideo } = await import('../lib/render.mjs');
247
+ const format = args.format ?? (args.out ? path.extname(args.out).slice(1).replace('mov', 'prores') || 'mp4' : 'mp4');
248
+ const ext = { mp4: 'mp4', hevc: 'mp4', prores: 'mov', webm: 'webm', gif: 'gif', png: 'png' }[format] ?? 'mp4';
249
+ const base = path.basename(path.dirname(path.resolve(file)));
250
+ const out = path.resolve(args.out ?? path.join(workDir(file), 'renders', `${base}${format === 'hevc' ? '-hevc' : ''}.${ext}`));
251
+ let lastPrint = 0;
252
+ const r = await withServer(file, (url) => renderVideo({
253
+ url: url('?render'), out, format,
254
+ workers: args.workers ? Number(args.workers) : undefined,
255
+ dpr: Number(args.dpr ?? 1),
256
+ from: args.from !== undefined ? Number(args.from) : undefined,
257
+ to: args.to !== undefined ? Number(args.to) : undefined,
258
+ fps: args.fps ? Number(args.fps) : undefined,
259
+ crf: args.crf ? Number(args.crf) : undefined,
260
+ audio: args.audio ? path.resolve(args.audio) : undefined,
261
+ sfx: !args['no-sfx'],
262
+ motionBlur: args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1,
263
+ shutter: args.shutter ? Number(args.shutter) : 180,
264
+ gifWidth: args['gif-width'] ? Number(args['gif-width']) : undefined,
265
+ gifFps: args['gif-fps'] ? Number(args['gif-fps']) : undefined,
266
+ onProgress: ({ done, total, elapsed }) => {
267
+ const now = Date.now();
268
+ if (now - lastPrint < 500 && done < total) return;
269
+ lastPrint = now;
270
+ const eta = done ? (elapsed / done) * (total - done) : 0;
271
+ process.stdout.write(`\rrendering ${done}/${total} frames · ${fmtTime(elapsed)} · eta ${fmtTime(eta)} `);
272
+ },
273
+ }));
274
+ process.stdout.write('\n');
275
+ console.log(`✓ ${path.relative(process.cwd(), r.out)} — ${r.frames} frames @ ${r.fps}fps, ${r.config.width}×${r.config.height}, ${fmtTime(r.seconds)} (${r.workers} workers)`);
276
+ if (args.loudness !== undefined && ['mp4', 'hevc', 'prores', 'webm'].includes(format)) {
277
+ // one pass of EBU R128 loudness on the audio, video copied untouched
278
+ const lufs = Number(args.loudness === true ? -16 : args.loudness);
279
+ const has = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', out], { encoding: 'utf8' }).stdout.trim();
280
+ if (!has) console.log(' (no audio to normalize)');
281
+ else {
282
+ const tmp = out.replace(/(\.[a-z0-9]+)$/i, '.norm$1');
283
+ const codec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : format === 'prores' ? ['-c:a', 'pcm_s16le'] : ['-c:a', 'aac', '-b:a', '320k'];
284
+ // EBU R128 loudnorm with a little extra peak headroom (AAC encoding of sharp transients can overshoot),
285
+ // then the file is measured and reported as it really is. Sparse sound design with no music or bed often
286
+ // lands a few LU under the target: that is the honest result, not something to crush.
287
+ const measure = (f) => { const e = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', f, '-vn', '-af', 'loudnorm=print_format=json', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 1 << 26 }).stderr; const j = JSON.parse(e.slice(e.lastIndexOf('{'), e.lastIndexOf('}') + 1) || '{}'); return { I: Number(j.input_i), TP: Number(j.input_tp) }; };
288
+ const chain = `loudnorm=I=${lufs}:TP=-2:LRA=11`;
289
+ const n = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', out, '-c:v', 'copy', '-af', chain, ...codec, '-ar', '48000', tmp]);
290
+ if (n.status === 0) {
291
+ fs.renameSync(tmp, out);
292
+ let { I, TP: P } = measure(out);
293
+ // loudnorm's dynamic mode drifts on short or sparse audio; one plain gain step brings it to the target,
294
+ // downward always, upward only as far as the -1.5 dBTP ceiling allows
295
+ let fix = lufs - I;
296
+ if (fix > 0) fix = Math.min(fix, -1.5 - P);
297
+ if (Math.abs(fix) > 0.3) {
298
+ const t2 = out.replace(/(\.[a-z0-9]+)$/i, '.trim$1');
299
+ const g = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', out, '-c:v', 'copy', '-af', `volume=${fix.toFixed(2)}dB`, ...codec, '-ar', '48000', t2]);
300
+ if (g.status === 0) { fs.renameSync(t2, out); ({ I, TP: P } = measure(out)); }
301
+ }
302
+ console.log(` loudness: ${I} LUFS integrated (target ${lufs}), true peak ${P} dBTP${Number(I) < lufs - 1 ? `\n (under the target: sparse sound design is kept rather than crushed; music or a quiet bed brings it up)` : ''}`);
303
+ } else console.error(` loudness pass failed: ${n.stderr}`);
304
+ }
305
+ }
306
+ break;
307
+ }
308
+
309
+ case 'sfx': {
310
+ await ensureDeps();
311
+ const file = args._[1];
312
+ if (!file) { console.error('Usage: sfx <scene.html> [--out file.wav]'); process.exit(1); }
313
+ const { renderSfx } = await import('../lib/render.mjs');
314
+ const base = path.basename(path.dirname(path.resolve(file)));
315
+ const out = path.resolve(args.out ?? path.join(workDir(file), 'renders', `${base}-sfx.wav`));
316
+ fs.mkdirSync(path.dirname(out), { recursive: true });
317
+ const r = await withServer(file, (url) => renderSfx({ url: url('?render'), out, from: args.from !== undefined ? Number(args.from) : 0, to: args.to !== undefined ? Number(args.to) : undefined }));
318
+ console.log(r ? `✓ ${path.relative(process.cwd(), r.out)} — ${r.cues} cues` : 'This scene declares no sound effects (s.sfx).');
319
+ break;
320
+ }
321
+
322
+ case 'board': {
323
+ if (args.url) {
324
+ // the live form: a paper-motion.app link the user opens to compare, tune and choose
325
+ const { encodeBoard, boardUrl } = await import('../lib/live-board.js');
326
+ const { getIndex, resolveRegistry, SITE, getItem } = await import('../lib/registry.mjs');
327
+ const { parseSet, setArgs } = await import('../lib/customize.mjs');
328
+ const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
329
+ const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
330
+ if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--site url]\n Prints a live board link. Values after the colon are your adjustments; the reply line the user pastes carries every non-default value.'); process.exit(args.help ? 0 : 1); }
331
+ const LABELS = ['A', 'B', 'C', 'D'];
332
+ const parseOne = async (spec) => {
333
+ const [id, kv] = spec.split(':'), it = index[id];
334
+ if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
335
+ if (!kv) return { id, values: {} };
336
+ const full = await getItem(id, reg), values = parseSet(full, kv.split(',').map((x) => x.trim()).filter(Boolean));
337
+ // keep only what differs from the defaults, so the link stays small and the reply line honest
338
+ const keep = Object.fromEntries(setArgs(full, values).map((a) => { const m = a.match(/^--set '?(\w+)=(.*?)'?$/); return [m[1], values[m[1]]]; }));
339
+ return { id, values: keep };
340
+ };
341
+ const notes = process.argv.flatMap((a, i, all) => (a === '--note' ? [all[i + 1]] : a.startsWith('--note=') ? [a.slice(7)] : []));
342
+ const options = [];
343
+ for (const [i, spec] of opts.entries()) {
344
+ if (args.kit) options.push({ label: LABELS[i], items: await Promise.all(spec.split(/\s+/).filter(Boolean).map(parseOne)), ...(notes[i] ? { note: notes[i] } : {}) });
345
+ else options.push({ label: LABELS[i], ...(await parseOne(spec)), ...(notes[i] ? { note: notes[i] } : {}) });
346
+ }
347
+ const look = {};
348
+ if (typeof args.look === 'string') for (const spec of args.look.split(',')) { const o = await parseOne(spec.trim()); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair, not "${o.id}"`); }
349
+ const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), options };
350
+ try { const frag = await encodeBoard(board, index); console.log(boardUrl(typeof args.site === 'string' ? args.site : SITE, frag)); }
351
+ catch (e) { console.error(e.message); process.exit(1); }
352
+ break;
353
+ }
354
+ await ensureDeps();
355
+ const files = args._.slice(1);
356
+ if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font] [--kit] (a live board link)'); process.exit(args.help ? 0 : 1); }
357
+ const { renderStills, renderVideo } = await import('../lib/render.mjs');
358
+ const { composeBoard } = await import('../lib/board.mjs');
359
+ const os = await import('node:os');
360
+ const clip = args.clip !== undefined ? Number(args.clip === true ? 5 : args.clip) : null;
361
+ const sequence = !!args.sequence;
362
+ if (sequence && !clip) { console.error('board: --sequence needs --clip <seconds>'); process.exit(1); }
363
+ const t = Number(args.t ?? 2);
364
+ const blur = args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1;
365
+ const out = path.resolve(args.out ?? (clip ? 'board.mp4' : 'board.png'));
366
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-board-'));
367
+ const raw = args.labels ? String(args.labels) : '';
368
+ const given = raw ? raw.split(raw.includes('|') ? '|' : ',').map((x) => x.trim()) : [];
369
+ const labels = files.map((_, i) => given[i] ?? String.fromCharCode(65 + i));
370
+ const inputs = [];
371
+ try {
372
+ for (const [i, file] of files.entries()) {
373
+ process.stdout.write(`\roption ${labels[i].split(' · ')[0]} · ${i + 1}/${files.length} `);
374
+ if (clip) {
375
+ const o = path.join(tmp, `${i}.mp4`);
376
+ await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1), motionBlur: blur }));
377
+ inputs.push(o);
378
+ } else {
379
+ const r = await withServer(file, (url) => renderStills({ url: url('?render'), times: [t], outDir: path.join(tmp, String(i)), sheet: false, dpr: Number(args.dpr ?? 1), motionBlur: blur }));
380
+ inputs.push(r.files[0]);
381
+ }
382
+ }
383
+ const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', inputs[0]], { encoding: 'utf8' });
384
+ const [w, h] = probe.stdout.trim().split(',').map(Number);
385
+ const r = sequence
386
+ ? await (await import('../lib/board.mjs')).composeSequence({ inputs, labels, out, tmp, duration: clip })
387
+ : await composeBoard({ inputs, labels, aspect: w / h, out, tmp, duration: clip ?? undefined, cols: args.cols ? Number(args.cols) : undefined });
388
+ console.log(`\r✓ ${path.relative(process.cwd(), r.out)} — ${files.length} options${sequence ? `, played in turn, ${clip * files.length} s` : `, ${r.W}×${r.H}`}`);
389
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
390
+ break;
391
+ }
392
+
393
+ case 'wordmark': {
394
+ await ensureDeps();
395
+ const text = args._[1];
396
+ if (!text || !args.family) { console.error('Usage: wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking 0em] [--color #fff] [--out wordmark.png]'); process.exit(1); }
397
+ const { renderWordmark } = await import('../lib/wordmark.mjs');
398
+ const r = await renderWordmark({
399
+ text: String(text), family: String(args.family), css: args.css, file: args.file ? path.resolve(args.file) : undefined,
400
+ weight: Number(args.weight ?? 400), tracking: args.tracking ?? '0em', color: args.color ?? '#ffffff',
401
+ size: Number(args.size ?? 400), pad: Number(args.pad ?? 0), out: path.resolve(args.out ?? 'wordmark.png'),
402
+ });
403
+ console.log(`✓ ${path.relative(process.cwd(), r.out)} — ${r.width}×${r.height}`);
404
+ break;
405
+ }
406
+
407
+ case 'sheet': {
408
+ await ensureDeps();
409
+ const brandDir = path.resolve(args.brand ?? 'brand');
410
+ if (!fs.existsSync(brandDir)) { console.error(`No brand folder at ${path.relative(process.cwd(), brandDir) || '.'} (run from the project root, or pass --brand)`); process.exit(1); }
411
+ const sheetDir = path.join(brandDir, 'sheet');
412
+ const page = path.join(sheetDir, 'index.html');
413
+ if (args.fresh || !fs.existsSync(page)) {
414
+ fs.mkdirSync(sheetDir, { recursive: true });
415
+ fs.copyFileSync(path.join(ENGINE, 'templates', 'brand-sheet', 'index.html'), page);
416
+ console.log(`${args.fresh ? 'replaced' : 'created'} ${path.relative(process.cwd(), page)}`);
417
+ }
418
+ if (args.serve) {
419
+ await withServer(page, async (url) => { console.log(`brand sheet: ${url()} (live reload on; Ctrl+C to stop)`); await new Promise(() => {}); }, { watch: true, port: Number(args.port ?? 4317) });
420
+ break;
421
+ }
422
+ const { launch } = await import('../lib/browser.mjs');
423
+ const width = Number(args.width ?? 1440);
424
+ await withServer(page, async (url) => {
425
+ const browser = await launch();
426
+ try {
427
+ const pg = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 });
428
+ const errs = [];
429
+ pg.on('pageerror', (e) => errs.push(e.message));
430
+ await pg.addInitScript(() => { window.__export = true; }); // live samples show their finished state in exports
431
+ await pg.goto(url());
432
+ await pg.waitForFunction(() => window.__sheetReady === true, null, { timeout: 60000 }).catch(() => { throw new Error(`sheet did not finish loading${errs.length ? `: ${errs[0]}` : ''}`); });
433
+ // show every clip at a telling moment (60 % in), not its first second
434
+ await pg.evaluate(async () => { await Promise.all([...document.querySelectorAll('video')].map((v) => new Promise((res) => {
435
+ const go = () => { v.pause(); v.currentTime = (v.duration || 1) * 0.6; v.onseeked = res; setTimeout(res, 3000); };
436
+ v.readyState >= 1 ? go() : (v.onloadedmetadata = go, setTimeout(res, 5000));
437
+ }))); });
438
+ await pg.waitForTimeout(800); // let the live shader draw
439
+ const png = path.join(sheetDir, 'sheet.png');
440
+ await pg.screenshot({ path: png, fullPage: true });
441
+ console.log(`✓ ${path.relative(process.cwd(), png)}`);
442
+ if (!args['no-pdf']) {
443
+ const h = await pg.evaluate(() => document.documentElement.scrollHeight);
444
+ const pdf = path.join(sheetDir, 'sheet.pdf');
445
+ await pg.pdf({ path: pdf, width: `${width}px`, height: `${h}px`, printBackground: true, pageRanges: '1' });
446
+ console.log(`✓ ${path.relative(process.cwd(), pdf)}`);
447
+ }
448
+ if (errs.length) console.error(`page errors:\n ${errs.join('\n ')}`);
449
+ } finally { await browser.close(); }
450
+ });
451
+ break;
452
+ }
453
+
454
+ case 'keys': {
455
+ const input = args._[1];
456
+ if (!input) { console.error('Usage: keys <recording> [--out brand/sound/keys]'); process.exit(1); }
457
+ const { extractKeys } = await import('../lib/keys.mjs');
458
+ const out = path.resolve(args.out ?? 'brand/sound/keys');
459
+ const { hits } = extractKeys(path.resolve(input), out, { minGap: Number(args['min-gap'] ?? 0.055), sensitivity: Number(args.sensitivity ?? 1) });
460
+ const heavy = hits.filter((h) => h.kind === 'heavy').length;
461
+ console.log(`✓ ${hits.length} keystrokes (${hits.length - heavy} letters, ${heavy} heavy) → ${path.relative(process.cwd(), out)}/keys.json`);
462
+ break;
463
+ }
464
+
465
+ case 'sound-samples': {
466
+ const brandDir = path.resolve(args.brand ?? 'brand');
467
+ const cfgFile = path.join(brandDir, 'sound.json');
468
+ if (!fs.existsSync(cfgFile)) { console.error(`No ${path.relative(process.cwd(), cfgFile)} (run the sound-kit skill first)`); process.exit(1); }
469
+ const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8'));
470
+ const { synthesize, writeWav } = await import('../lib/sfx.mjs');
471
+ const outDir = path.join(brandDir, 'sound');
472
+ fs.mkdirSync(outDir, { recursive: true });
473
+ const list = [];
474
+ const lowpass = cfg.lowpass ?? 2000;
475
+ // samples keep the brand's balance: each is scaled by its cue's gain against the loudest cue,
476
+ // so a "very quiet" bed plays very quiet on the sheet too
477
+ const gains = Object.values(cfg.cues ?? {}).filter(Boolean).map((c) => c.gain ?? 0.2);
478
+ const top = Math.max(...gains, 1e-6);
479
+ for (const [name, cue] of Object.entries(cfg.cues ?? {})) {
480
+ if (!cue) continue;
481
+ const dur = (cue.dur ?? 0.8) + 0.6;
482
+ writeWav(path.join(outDir, `${name}.wav`), synthesize([{ t: 0.05, ...cue, ...(name === 'bed' ? { dur: Math.min(cue.dur ?? 6, 6) } : {}) }], { duration: name === 'bed' ? 6.2 : dur, lowpass, peak: 0.7 * Math.max(0.08, (cue.gain ?? 0.2) / top) }));
483
+ list.push({ name, file: `${name}.wav` });
484
+ }
485
+ // a typing sample only when the brand types (motion.json text: typing, or keys configured)
486
+ const motionCfg = fs.existsSync(path.join(brandDir, 'motion.json')) ? JSON.parse(fs.readFileSync(path.join(brandDir, 'motion.json'), 'utf8')) : {};
487
+ const k = cfg.keys ?? {};
488
+ const types = motionCfg.text === 'typing' || !!(k && (k.bank || k.style));
489
+ if (!types) { fs.writeFileSync(path.join(outDir, 'sounds.json'), JSON.stringify(list, null, 1) + '\n'); console.log(`✓ ${list.length} samples → ${path.relative(process.cwd(), outDir)}/sounds.json`); break; }
490
+ const line = 'make a video';
491
+ let t = 0.1; const cues = [];
492
+ [...line].forEach((ch, i) => { cues.push({ t, type: 'key', kind: ch === ' ' ? 'space' : 'letter', gain: k.gain ?? 0.16, seed: i + 1, ...(k.bank ? { bank: k.bank } : {}), ...(k.style ? { style: k.style } : {}) }); t += ch === ' ' ? 0.16 : 0.07 + ((i * 37) % 5) / 100; });
493
+ cues.push({ t: t + 0.3, type: 'key', kind: 'enter', gain: (k.gain ?? 0.16) * 1.1, seed: 99, ...(k.bank ? { bank: k.bank } : {}), ...(k.style ? { style: k.style } : {}) });
494
+ writeWav(path.join(outDir, 'typing.wav'), synthesize(cues, { duration: t + 0.8, lowpass, peak: 0.7 }));
495
+ list.push({ name: 'typing', file: 'typing.wav' });
496
+ fs.writeFileSync(path.join(outDir, 'sounds.json'), JSON.stringify(list, null, 1) + '\n');
497
+ console.log(`✓ ${list.length} samples → ${path.relative(process.cwd(), outDir)}/sounds.json`);
498
+ break;
499
+ }
500
+
501
+ case 'shader-set': {
502
+ await ensureDeps();
503
+ const sub = args._[1];
504
+ const dir = path.join(path.resolve(args.brand ?? 'brand'), 'shader-set');
505
+ const { buildSet, writeSet, shotName } = await import('../lib/shaderset.mjs');
506
+ const built = buildSet(dir);
507
+ for (const m of built.skipped) console.warn(` skipped ${m}`);
508
+ if (sub === 'build') { console.log(`✓ ${built.shots.length} shots, ${built.boards.length} boards in ${path.relative(process.cwd(), dir)}`); break; }
509
+ if (sub === 'boards') {
510
+ const { renderStills } = await import('../lib/render.mjs');
511
+ const os = await import('node:os');
512
+ for (const b of built.boards) {
513
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-set-'));
514
+ const r = await withServer(b, (url) => renderStills({ url: url('?render'), times: [Number(args.t ?? 2)], outDir: tmp, sheet: false }));
515
+ const png = b.replace(/\.html$/, '.png');
516
+ fs.copyFileSync(r.files[0], png); fs.rmSync(tmp, { recursive: true, force: true });
517
+ console.log(`✓ ${path.relative(process.cwd(), png)}`);
518
+ }
519
+ break;
520
+ }
521
+ if (sub === 'render') {
522
+ const { renderVideo } = await import('../lib/render.mjs');
523
+ const only = args.only ? String(args.only).split(',').map((x) => x.trim()) : null;
524
+ const pick = built.shots.filter(({ entry, index }) => !only || only.includes(String(index + 1)) || only.includes(entry.id));
525
+ fs.mkdirSync(path.join(dir, 'renders'), { recursive: true });
526
+ for (const { entry, index, file } of pick) {
527
+ const out = path.join(dir, 'renders', `${shotName(entry, index)}.mp4`);
528
+ await withServer(file, (url) => renderVideo({ url: url('?render'), out, sfx: false, crf: 16, motionBlur: Number(args['motion-blur'] ?? 4) }));
529
+ built.set.entries[index].video = `renders/${shotName(entry, index)}.mp4`;
530
+ writeSet(dir, built.set);
531
+ console.log(`✓ ${path.relative(process.cwd(), out)}`);
532
+ }
533
+ break;
534
+ }
535
+ console.error('Usage: shader-set <build|boards|render> [--brand brand] [--only 3,7]'); process.exit(1);
536
+ }
537
+
538
+ case 'status': {
539
+ const B = path.resolve(args.brand ?? 'brand');
540
+ const has = (f) => fs.existsSync(path.join(B, f));
541
+ const filled = (f) => has(f) && fs.readFileSync(path.join(B, f), 'utf8').replace(/^#.*$/gm, '').replace(/^## Open[\s\S]*$/m, '').trim().length > 40;
542
+ const n = (dir, re) => (has(dir) ? fs.readdirSync(path.join(B, dir)).filter((f) => re.test(f)).length : 0);
543
+ let setN = 0, setR = 0;
544
+ if (has('shader-set/set.json')) { const j = JSON.parse(fs.readFileSync(path.join(B, 'shader-set/set.json'), 'utf8')); const e = Array.isArray(j) ? j : j.entries ?? []; setN = e.length; setR = e.filter((x) => x.video && fs.existsSync(path.join(B, 'shader-set', x.video))).length; }
545
+ const piecesDone = has('pieces') ? fs.readdirSync(path.join(B, 'pieces')).filter((p) => fs.existsSync(path.join(B, 'pieces', p, 'renders')) && fs.readdirSync(path.join(B, 'pieces', p, 'renders')).some((f) => f.endsWith('.mp4') && !/test/.test(f))).length : 0;
546
+ const steps = [
547
+ ['identity', filled('IDENTITY.md'), 'IDENTITY.md'],
548
+ ['visual-system', has('tokens.json') && filled('VISUAL.md'), 'VISUAL.md + tokens.json' + (has('assets/wordmark-on-light.png') || has('assets/wordmark-on-dark.png') ? ' + wordmark' : '')],
549
+ ['brand-sheet', has('sheet/sheet.png'), 'sheet/sheet.png'],
550
+ ['motion-style', has('motion.json'), 'MOTION.md + motion.json' + (n('motion', /\.mp4$/) ? ` + ${n('motion', /\.mp4$/)} move clips` : '')],
551
+ ['sound-kit', has('sound.json'), 'SOUND.md + sound.json' + (has('sound/keys/keys.json') ? ' + keystroke bank' : '')],
552
+ ['shader-set', setN > 0, `shader-set: ${setN} entries, ${setR} rendered${setR < setN ? ' (render the rest: shader-set render)' : ''}`],
553
+ ['make-video', piecesDone > 0, `pieces: ${piecesDone} rendered${n('pieces', /^[^.]/) > piecesDone ? `, ${n('pieces', /^[^.]/) - piecesDone} in progress` : ''}`],
554
+ ];
555
+ if (!fs.existsSync(B)) { console.log(`No brand folder at ${path.relative(process.cwd(), B) || '.'} yet. Start with: identity`); break; }
556
+ for (const [skill, done, what] of steps) console.log(`${done ? '✓' : '·'} ${skill.padEnd(14)} ${done ? what : ''}`);
557
+ console.log(` decisions ${n('decisions', /\.md$/)}`);
558
+ const next = steps.find(([, done]) => !done);
559
+ console.log(next ? `\nnext: ${next[0]}` : '\nThe brand is complete: make another piece with make-video, or revisit any part.');
560
+ break;
561
+ }
562
+
563
+ case 'shaders': {
564
+ await ensureDeps();
565
+ const { buildCatalog, catalogMarkdown } = await import('../lib/catalog.mjs');
566
+ const cat = await buildCatalog();
567
+ const version = JSON.parse(fs.readFileSync(path.join((await import('../lib/modules.mjs')).moduleDir('@paper-design/shaders-react'), 'package.json'), 'utf8')).version;
568
+ const name = args._[1];
569
+ const list = name ? cat.filter((s) => s.name.toLowerCase() === name.toLowerCase()) : cat;
570
+ if (args.md) {
571
+ const md = catalogMarkdown(cat, version);
572
+ const dest = path.resolve(args.out ?? (fs.existsSync(REPO_SKILL) ? path.join(REPO_SKILL, 'references', 'shaders.md') : 'shaders.md'));
573
+ fs.writeFileSync(dest, md);
574
+ console.log(`wrote ${dest}`);
575
+ } else if (args.json || name) console.log(JSON.stringify(list, null, 2));
576
+ else for (const s of cat) console.log(`${s.name.padEnd(22)} ${s.animated ? 'anim ' : 'still'} ${s.usesImage ? 'img' : ' '} ${s.description.split('. ')[0]}`);
577
+ break;
578
+ }
579
+
580
+ case 'add': {
581
+ const ids = args._.slice(1);
582
+ if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
583
+ const { add } = await import('../lib/registry.mjs');
584
+ const set = process.argv.flatMap((a, i, all) => (a === '--set' ? [all[i + 1]] : a.startsWith('--set=') ? [a.slice(6)] : [])).filter(Boolean);
585
+ const report = await add(ids.filter((x) => !set.includes(x)), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, set });
586
+ for (const r of report) {
587
+ console.log(`✓ ${r.id} (${r.type})${r.wrote.length ? ` → ${[...new Set(r.wrote)].join(', ')}` : ''}`);
588
+ for (const f of r.skipped) console.log(` kept your ${f} (it differs; --force to replace)`);
589
+ for (const c of r.conflicts) console.log(` kept your value at ${c} (--force to replace)`);
590
+ }
591
+ const last = report.filter((r) => ids.includes(r.id) && r.usage);
592
+ if (last.length === 1) console.log(`\nUsage:\n${last[0].usage.split('\n').map((l) => ` ${l}`).join('\n')}`);
593
+ break;
594
+ }
595
+
596
+ case 'list':
597
+ case 'search': {
598
+ const { search } = await import('../lib/registry.mjs');
599
+ const isList = cmd === 'list';
600
+ if (args.help) { console.log(isList ? 'Usage: list [type] [--json]' : 'Usage: search <words> [--type t] [--mode light|dark] [--mood m] [--motion still|gentle|lively] [--role r] [--energy 2-4] [--near "#hex,#hex"] [--json]\n --near sorts by color fit: the mean distance from each resource color to the nearest of yours (lower is closer); pass your ground, figure and accent.'); break; }
601
+ const items = await search(isList ? '' : args._.slice(1).join(' '), { type: isList ? args._[1] : args.type, mode: args.mode, mood: args.mood, motion: args.motion, role: args.role, energy: args.energy, near: args.near, registry: args.registry });
602
+ if (args.json) { console.log(JSON.stringify(items, null, 2)); break; }
603
+ let type = '';
604
+ if (items.some((i) => i.fit !== undefined)) console.log('fit: mean distance to your nearest color, lower is closer');
605
+ for (const i of items) { if (i.type !== type) { type = i.type; console.log(`\n${type}`); } console.log(` ${i.id.padEnd(18)} ${[i.mode, i.motion, i.role, i.energy !== undefined ? `energy ${i.energy}` : null, i.fit !== undefined ? `fit ${Math.round(i.fit)}` : null].filter(Boolean).join(' · ').padEnd(26)} ${i.summary}`); }
606
+ if (!items.length) console.log('nothing found');
607
+ break;
608
+ }
609
+
610
+ case 'registry': {
611
+ const sub = args._[1];
612
+ if (sub === 'previews') {
613
+ // one clip (webm, 1280x720) and one poster (jpg) per code resource, from registry/_demo/index.html?demo=<id>
614
+ const { loadItems, resolveRegistry } = await import('../lib/registry.mjs');
615
+ const { renderVideo } = await import('../lib/render.mjs');
616
+ const os = await import('node:os');
617
+ const reg = resolveRegistry();
618
+ if (!reg.dir) { console.error('registry previews runs inside the paper-motion repo'); process.exit(1); }
619
+ const only = typeof args.only === 'string' ? args.only.split(',') : null;
620
+ const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
621
+ fs.mkdirSync(out, { recursive: true });
622
+ const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe'].includes(i.type) && (!only || only.includes(i.id)));
623
+ const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5 };
624
+ const demo = path.join(reg.dir, '_demo', 'index.html');
625
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
626
+ try {
627
+ for (const [k, it] of items.entries()) {
628
+ process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
629
+ const mp4 = path.join(tmp, `${it.id}.mp4`);
630
+ if (it.type === 'recipe') {
631
+ // a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
632
+ const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
633
+ fs.copyFileSync(path.join(reg.dir, 'recipe', it.id, 'scene.html'), copy);
634
+ process.chdir(path.join(reg.dir, '_demo'));
635
+ try { await withServer(copy, (url) => renderVideo({ url: url('?render'), out: mp4, sfx: false, crf: 18, motionBlur: Number(args['motion-blur'] ?? 3) })); }
636
+ finally { process.chdir(here); fs.rmSync(copy, { force: true }); }
637
+ } else await withServer(demo, (url) => renderVideo({ url: url(`?render&demo=${it.id}`), out: mp4, from: 0, to: 5, sfx: false, crf: 16, motionBlur: Number(args['motion-blur'] ?? 4) }));
638
+ const vf = 'scale=1280:-2';
639
+ const a = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', mp4, '-vf', vf, '-c:v', 'libvpx-vp9', '-crf', '38', '-b:v', '0', '-row-mt', '1', '-an', path.join(out, `${it.id}.webm`)]);
640
+ const b = spawnSync('ffmpeg', ['-v', 'error', '-y', '-ss', String(POSTER[it.type] ?? 1.5), '-i', mp4, '-frames:v', '1', '-vf', vf, '-q:v', '4', path.join(out, `${it.id}.jpg`)]);
641
+ if (a.status || b.status) throw new Error(`ffmpeg failed for ${it.id}: ${a.stderr}${b.stderr}`);
642
+ }
643
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
644
+ const kb = Math.round(fs.readdirSync(out).reduce((n, f) => n + fs.statSync(path.join(out, f)).size, 0) / 1024);
645
+ console.log(`\r✓ ${items.length} previews → ${path.relative(process.cwd(), out)} (${kb} KB in all)`);
646
+ break;
647
+ }
648
+ if (sub !== 'build') { console.error('Usage: registry build [--out dir] [--site url] | registry previews [--only ids] [--motion-blur 4]'); process.exit(1); }
649
+ const { build, resolveRegistry, SITE } = await import('../lib/registry.mjs');
650
+ const reg = resolveRegistry();
651
+ if (!reg.dir) { console.error('registry build runs inside the paper-motion repo (no registry/ folder found)'); process.exit(1); }
652
+ const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public'));
653
+ try { const r = build({ dir: reg.dir, out, site: typeof args.site === 'string' ? args.site.replace(/\/$/, '') : SITE }); console.log(`✓ ${r.count} resources → ${path.relative(process.cwd(), out) || '.'} (registry.json, r/, llms.txt, llms-full.txt)`); }
654
+ catch (e) { console.error(e.message); process.exit(1); }
655
+ break;
656
+ }
657
+
658
+ default:
659
+ console.log(HELP);
660
+ if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1);
661
+ }
662
+ }
663
+
664
+ main().catch((e) => { console.error(`\n✗ ${e.message}`); process.exit(1); });