paper-motion 0.1.0 → 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 (75) hide show
  1. package/README.md +14 -0
  2. package/bin/pamo.mjs +133 -4
  3. package/examples/halo-explainer/audio/script.txt +8 -0
  4. package/examples/halo-explainer/audio/words.json +554 -0
  5. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  6. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  7. package/examples/halo-explainer/brand/motion.json +9 -0
  8. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  9. package/examples/halo-explainer/brand/sound.json +33 -0
  10. package/examples/halo-explainer/brand/tokens.json +97 -0
  11. package/examples/halo-explainer/brief.md +34 -0
  12. package/examples/halo-explainer/poster.jpg +0 -0
  13. package/examples/halo-explainer/scene.html +372 -0
  14. package/examples/orbit-keynote/audio/script.txt +7 -0
  15. package/examples/orbit-keynote/audio/words.json +494 -0
  16. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  17. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  18. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  19. package/examples/orbit-keynote/brand/motion.json +9 -0
  20. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  21. package/examples/orbit-keynote/brand/sound.json +45 -0
  22. package/examples/orbit-keynote/brand/tokens.json +106 -0
  23. package/examples/orbit-keynote/brief.md +25 -0
  24. package/examples/orbit-keynote/poster.jpg +0 -0
  25. package/examples/orbit-keynote/scene.html +323 -0
  26. package/lib/customize.mjs +162 -0
  27. package/lib/live-board.js +128 -0
  28. package/lib/registry.mjs +344 -0
  29. package/lib/render.mjs +3 -3
  30. package/lib/server.mjs +1 -0
  31. package/lib/sfx.mjs +5 -3
  32. package/package.json +1 -1
  33. package/runtime/core.js +269 -0
  34. package/runtime/index.js +3 -260
  35. package/runtime/kit.js +98 -0
  36. package/runtime/moves/bar-reveal.js +19 -0
  37. package/runtime/moves/blur-in.js +7 -0
  38. package/runtime/moves/cells.js +17 -0
  39. package/runtime/moves/crossfade.js +6 -0
  40. package/runtime/moves/cut.js +3 -0
  41. package/runtime/moves/dive-in.js +21 -0
  42. package/runtime/moves/draw-on.js +12 -0
  43. package/runtime/moves/drift.js +7 -0
  44. package/runtime/moves/flash.js +15 -0
  45. package/runtime/moves/flip-3d.js +9 -0
  46. package/runtime/moves/flip-in.js +12 -0
  47. package/runtime/moves/flip-lift.js +24 -0
  48. package/runtime/moves/glitch.js +16 -0
  49. package/runtime/moves/iris.js +12 -0
  50. package/runtime/moves/magic-move.js +49 -0
  51. package/runtime/moves/mask-reveal.js +10 -0
  52. package/runtime/moves/odometer.js +32 -0
  53. package/runtime/moves/orbit.js +8 -0
  54. package/runtime/moves/pop-in.js +12 -0
  55. package/runtime/moves/punch.js +9 -0
  56. package/runtime/moves/push.js +14 -0
  57. package/runtime/moves/rgb-split.js +7 -0
  58. package/runtime/moves/roll.js +20 -0
  59. package/runtime/moves/scan-reveal.js +10 -0
  60. package/runtime/moves/scramble.js +21 -0
  61. package/runtime/moves/shake.js +15 -0
  62. package/runtime/moves/slices.js +14 -0
  63. package/runtime/moves/snap-scroll.js +23 -0
  64. package/runtime/moves/squash-drop.js +21 -0
  65. package/runtime/moves/tile-swirl.js +29 -0
  66. package/runtime/moves/track-in.js +7 -0
  67. package/runtime/moves/type-on.js +41 -0
  68. package/runtime/moves/unfold.js +20 -0
  69. package/runtime/moves/whip-pan.js +21 -0
  70. package/runtime/moves/whip.js +14 -0
  71. package/runtime/moves/wipe.js +9 -0
  72. package/runtime/moves/word-rise.js +10 -0
  73. package/runtime/moves/zoom-through.js +13 -0
  74. package/runtime/moves.js +66 -146
  75. package/templates/move-demo/scene.html +4 -3
package/README.md CHANGED
@@ -29,6 +29,20 @@ npx paper-motion # every command
29
29
 
30
30
  Installed globally (`npm i -g paper-motion`), the same commands run as `paper-motion` or the short `pamo`.
31
31
 
32
+ ### Resources from paper-motion.app
33
+
34
+ The registry at [paper-motion.app](https://paper-motion.app) holds named moves, backgrounds, palettes, font pairs and recipes. Agents pick them for a brand, propose options as a board link, and install what the user chooses as code the user owns.
35
+
36
+ ```bash
37
+ npx paper-motion search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff" # find resources by mood, mode, color fit
38
+ npx paper-motion add magic-move mesh-sage --set mesh-sage:speed=0.8 # install, with your values written in
39
+ npx paper-motion board --url --title "Backgrounds" --look cobalt,geist \
40
+ --option mesh-graphite --option neuro-slate --option grain-slate # a live board link for the user to choose on
41
+ npx paper-motion list background # every resource of a type
42
+ ```
43
+
44
+ `https://paper-motion.app/llms.txt` explains the flow to an agent.
45
+
32
46
  ## Inside a scene
33
47
 
34
48
  ```js
package/bin/pamo.mjs CHANGED
@@ -33,7 +33,12 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
33
33
  (sound effects declared with s.sfx() are mixed in)
34
34
  sfx <scene.html> [--out file.wav] [--from s] [--to s]
35
35
  export only the synthesized sound effects
36
- board <a.html> <b.html> [...] [--t 2 | --clip 5] [--labels "A · calm|B · loud"] [--cols n] [--out board.png]
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]
37
42
  option board for interviews: 2–6 options side by side,
38
43
  lettered A, B, C…; a still (.png) or short clips (.mp4).
39
44
  Labels split on | (or on commas when there is no |).
@@ -61,6 +66,20 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
61
66
  status [--brand brand] what the brand folder has, and the next step on the main flow
62
67
  shaders [--json | --md [--out f]] [name]
63
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
64
83
  `;
65
84
 
66
85
  function parse(argv) {
@@ -301,9 +320,40 @@ async function main() {
301
320
  }
302
321
 
303
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
+ }
304
354
  await ensureDeps();
305
355
  const files = args._.slice(1);
306
- if (files.length < 2 || files.length > 6) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png]'); process.exit(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); }
307
357
  const { renderStills, renderVideo } = await import('../lib/render.mjs');
308
358
  const { composeBoard } = await import('../lib/board.mjs');
309
359
  const os = await import('node:os');
@@ -311,6 +361,7 @@ async function main() {
311
361
  const sequence = !!args.sequence;
312
362
  if (sequence && !clip) { console.error('board: --sequence needs --clip <seconds>'); process.exit(1); }
313
363
  const t = Number(args.t ?? 2);
364
+ const blur = args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1;
314
365
  const out = path.resolve(args.out ?? (clip ? 'board.mp4' : 'board.png'));
315
366
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-board-'));
316
367
  const raw = args.labels ? String(args.labels) : '';
@@ -322,10 +373,10 @@ async function main() {
322
373
  process.stdout.write(`\roption ${labels[i].split(' · ')[0]} · ${i + 1}/${files.length} `);
323
374
  if (clip) {
324
375
  const o = path.join(tmp, `${i}.mp4`);
325
- await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1) }));
376
+ await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1), motionBlur: blur }));
326
377
  inputs.push(o);
327
378
  } else {
328
- 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) }));
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 }));
329
380
  inputs.push(r.files[0]);
330
381
  }
331
382
  }
@@ -526,6 +577,84 @@ async function main() {
526
577
  break;
527
578
  }
528
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
+
529
658
  default:
530
659
  console.log(HELP);
531
660
  if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1);
@@ -0,0 +1,8 @@
1
+ Your solar panels make the most power at noon.
2
+ But you use the most at night.
3
+ Halo stores the difference.
4
+ One: sunlight hits your panels.
5
+ Two: whatever you don't use flows into Halo, a battery the size of a suitcase.
6
+ Three: when the sun goes down, your home runs on stored sunlight.
7
+ Lower bills. Lights on, even in a blackout.
8
+ Halo. Keep the sun after dark.