paper-motion 0.2.6 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
+ ## 0.3.0 · 2026-11-11
6
+
7
+ - **Studio**: `pamo studio` serves a local page where the user follows a piece stage by stage (brief, look, structure, parts, assembly, final), watches each stage's renders, approves or asks for changes, and edits the brief, the words on screen (a part's CONFIG texts, re-rendered on the spot), lengths, joins and order (drag and drop). `pieces/<piece>/piece.json` holds the stages and what the user did; `pamo piece init | review | working | status` is the agent's side (`review` hands a stage over and waits for the decision).
8
+ - The studio is the default way to make a piece: `pamo piece init` starts the project's studio in the background (one per project, on the first free port from 4317; `pamo studio --stop` ends it), and the first `pamo piece review` opens it in the user's browser (`--no-open` to skip). `llms.txt` and `make-video` now start every piece there.
9
+ - The studio adapts to tablets and phones.
10
+ - `pamo chain <piece> --drafts [--only dir]` renders each part alone, quickly, for review; `--draft` joins a quick version.
11
+ - **Chains**: `pamo chain init <piece>` installs the blocks of a chosen structure (`--board <id> --option D`, or `--parts id:sec,…`) as one scene each under `pieces/<piece>/` with a `chain.json`; `pamo chain <piece>` renders them in the brand's look and joins them into one video (`whip`, `cut`, `dissolve`, `dip`), keeping each part's sound and setting the loudness once. `--check` warns when a part is cut before its land.
12
+ - Boards: a structure beat may name the block or shot that shows it (`[block-id]` in `--option "@Title | 18 | Hook 0-2 [word-swap] …"`, `id` in the payload, checked against the registry); the board plays that clip for the beat.
13
+ - Scenes expose the gestures they cue (`B.cue`) as `window.__pm.marks`.
14
+ - Blocks with a dark panel keep their type light in light brands (`search-type`, `browser-frame`, `terminal-run`, `light-sweep`); `drag-drop`'s cards follow the brand's mode.
15
+
16
+ ## 0.2.7 · 2026-11-10
17
+
18
+ - A new resource type, **blocks**: 29 ready-made 4–8 s moments (annotations, interfaces, code, data, type, brand, effects), installed like recipes and customized through their `CONFIG`; each recomposes for 16:9, 9:16 and 1:1.
19
+ - `pamo registry previews` renders blocks; `pamo add --set` writes their values into `CONFIG`.
20
+
5
21
  ## 0.2.6 · 2026-11-10
6
22
 
7
23
  - `camera()` in `paper-motion/kit`: a 3D world for a piece (`place`, `at`, `travel` with keyframes, `focus` with depth of field and gradual exits). Every recipe is rebuilt on it: a corridor the camera travels (launch, manifesto), a column it descends (three-things), a line it dollies along with a pull-back recap (explainer), a window it tilts into and punches (product-demo), a slab it swings around (before-after), a sleeve it orbits (release).
package/bin/pamo.mjs CHANGED
@@ -45,6 +45,30 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
45
45
  --cols 1 stacks options full width (judge type at real size)
46
46
  --sequence plays the clips one after another, with their sound
47
47
  (for choices you hear: sound palettes, typing)
48
+ chain init <piece> (--board <id|link> [--option D] | --parts id:sec,id:sec,…) [--format 1920x1080]
49
+ [--join whip|cut|dissolve|dip] [--title "…"] [--force]
50
+ a piece made of blocks: installs each part as its own scene under
51
+ pieces/<piece>/<nn>-<id>/ (edit each CONFIG) and writes chain.json;
52
+ --board takes the structure the user chose on a board; with neither,
53
+ installs parts you added to chain.json and keeps the rest
54
+ chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--dpr 1] [--check]
55
+ [--drafts [--only dir,…]] [--draft]
56
+ renders every part in the brand's look and joins them into one
57
+ video (run from the project root); --check only plans and warns;
58
+ --drafts renders each part alone, quickly, for the studio's Parts
59
+ stage; --draft joins a quick version for its Assembly stage
60
+ piece init <piece> [--title "…"] [--what "…"] [--who "…"] [--format "…"] [--tone "…"] [--action "…"]
61
+ [--no-studio] start tracking a piece stage by stage (pieces/<piece>/piece.json)
62
+ and start this project's studio in the background
63
+ piece review <piece> <stage> [--wait 30] [--no-wait] [--no-open]
64
+ hand a stage to the user in the studio (brief, look, structure,
65
+ parts, assembly, final) and wait for their decision; prints it.
66
+ Starts the studio if needed; the first time, opens it for them
67
+ piece working <piece> <stage> mark a stage as yours again (after changes were asked)
68
+ piece status <piece> where the piece is, and what the user did that you haven't read
69
+ studio [--port 4317] [--open] [--stop]
70
+ the local studio: the user follows each piece, approves, edits
71
+ (one per project; --stop ends the one running in the background)
48
72
  wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking -0.02em]
49
73
  [--color #fff] [--size 400] [--pad 0] [--out wordmark.png]
50
74
  the name set in its typeface, as a transparent PNG
@@ -327,13 +351,13 @@ async function main() {
327
351
  const { parseSet, setArgs } = await import('../lib/customize.mjs');
328
352
  const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
329
353
  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] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action".\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
354
+ 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] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
331
355
  const LABELS = ['A', 'B', 'C', 'D'];
332
356
  const parseOne = async (spec) => {
333
357
  if (spec.startsWith('@')) {
334
358
  // a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
335
359
  const [title, dur, beatsSpec] = spec.slice(1).split('|').map((x) => x.trim());
336
- const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to what"`); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: m[4] }; });
360
+ const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to [block-id] what"`); const sh = m[4].match(/^\[([\w-]+)\]\s*(.+)$/); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: sh ? sh[2] : m[4], ...(sh ? { id: sh[1] } : {}) }; });
337
361
  return { structure: { title, duration: Number(dur), beats } };
338
362
  }
339
363
  const [id, kv] = spec.split(':'), it = index[id];
@@ -417,6 +441,103 @@ async function main() {
417
441
  break;
418
442
  }
419
443
 
444
+ case 'chain': {
445
+ const sub = args._[1] === 'init' ? 'init' : 'render';
446
+ const piece = sub === 'init' ? args._[2] : args._[1];
447
+ if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] | --parts id:sec,…] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--check]'); process.exit(args.help ? 0 : 1); }
448
+ const chainLib = await import('../lib/chain.mjs');
449
+ if (sub === 'init') {
450
+ const { SITE } = await import('../lib/registry.mjs');
451
+ let src;
452
+ if (args.board) src = await chainLib.structureFromBoard(String(args.board), typeof args.option === 'string' ? args.option : undefined, { site: typeof args.site === 'string' ? args.site : SITE });
453
+ else if (typeof args.parts === 'string') src = { parts: chainLib.partsFromList(args.parts) };
454
+ else src = {}; // no source: sync an existing chain.json (install parts added since)
455
+ const r = await chainLib.initChain({ piece, title: typeof args.title === 'string' ? args.title : src.title, parts: src.parts, format: typeof args.format === 'string' ? args.format : undefined, join: typeof args.join === 'string' ? args.join : undefined, registry: args.registry, force: !!args.force });
456
+ console.log(`✓ ${path.relative(process.cwd(), r.dir)}/chain.json · "${r.chain.title}" · ${r.chain.parts.length} parts, ${r.chain.format}`);
457
+ for (const p of r.chain.parts) console.log(` ${p.dir ?? '(no block)'}${p.name ? ` · ${p.name}` : ''}${p.seconds ? ` · ${p.seconds} s` : ''}${p.what ? ` — ${p.what}` : ''}`);
458
+ for (const n of r.notes) console.log(` ! ${n}`);
459
+ console.log(`\nNext: write each part's text and data into its CONFIG (the "what" above is the brief), then:\n npx paper-motion chain ${piece} --motion-blur 5`);
460
+ break;
461
+ }
462
+ await ensureDeps();
463
+ if (args.check) {
464
+ const r = await chainLib.checkChain({ piece });
465
+ for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.from}–${+(p.from + p.seconds).toFixed(2)} s of ${p.D}`);
466
+ for (const w of r.warnings) console.log(` ! ${w}`);
467
+ console.log(`${r.total.toFixed(1)} s in all`);
468
+ break;
469
+ }
470
+ if (args.drafts) {
471
+ const only = typeof args.only === 'string' ? args.only.split(',').map((x) => x.trim()) : undefined;
472
+ const r = await chainLib.renderDrafts({ piece, only, onStep: (e) => { if (e.warning) console.log(` ! ${e.warning}`); else process.stdout.write(`\rdraft ${e.part}/${e.of} · ${e.dir} `); } });
473
+ console.log(`\r✓ ${r.rendered.length} draft${r.rendered.length === 1 ? '' : 's'} in ${path.relative(process.cwd(), path.join(chainLib.pieceDir(piece), 'drafts')) || '.'}${r.rendered.length ? '' : ' (every part is approved; --only <dir> to redo one)'} `);
474
+ break;
475
+ }
476
+ const blur = args['motion-blur'] ? Number(args['motion-blur'] === true ? 5 : args['motion-blur']) : 1;
477
+ const r = await chainLib.renderChain({ draft: !!args.draft, piece, out: typeof args.out === 'string' ? args.out : undefined, motionBlur: blur, dpr: Number(args.dpr ?? 1), audio: typeof args.audio === 'string' ? args.audio : undefined, sfx: !args['no-sfx'],
478
+ onStep: (e) => { if (e.warning) console.log(` ! ${e.warning}`); else if (e.part) process.stdout.write(`\rpart ${e.part}/${e.of} · ${e.dir} `); else if (e.joining) process.stdout.write('\rjoining… '); } });
479
+ console.log(`\r✓ ${path.relative(process.cwd(), r.out)} — ${r.parts} parts, ${r.total.toFixed(1)} s, ${r.W}×${r.H}`);
480
+ break;
481
+ }
482
+
483
+ case 'piece': {
484
+ const sub = args._[1], piece = args._[2];
485
+ if (!['init', 'review', 'working', 'status'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …]\n piece review <piece> <stage> [--wait 30] [--no-wait]\n piece working <piece> <stage>\n piece status <piece>'); process.exit(args.help ? 0 : 1); }
486
+ const P = await import('../lib/piece.mjs');
487
+ const { pieceDir } = await import('../lib/chain.mjs');
488
+ const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
489
+ const studio = async () => (args.studio === false || args['no-studio'] ? null : studioLib.ensureStudio(process.cwd(), { bin: fileURLToPath(import.meta.url) }));
490
+ if (sub === 'init') {
491
+ const brief = Object.fromEntries(P.BRIEF_KEYS.filter((k) => typeof args[k] === 'string').map((k) => [k, args[k]]));
492
+ const p = P.initPiece({ piece, title: typeof args.title === 'string' ? args.title : undefined, brief, force: !!args.force });
493
+ console.log(`✓ ${path.relative(process.cwd(), dir)}/piece.json · "${p.title}" · stage ${p.stage}`);
494
+ const missing = P.BRIEF_KEYS.filter((k) => !p.brief[k]);
495
+ if (missing.length) console.log(` brief still empty: ${missing.join(', ')} (pass them, or write piece.json)`);
496
+ const s = await studio().catch((e) => { console.log(` ! ${e.message}`); return null; });
497
+ if (s) console.log(` studio: ${s.url}${q}${s.started ? ' (started in the background; npx paper-motion studio --stop ends it)' : ''}`);
498
+ console.log(`\nNext: npx paper-motion piece review ${piece} brief (opens the studio for the user and waits for their decision)`);
499
+ break;
500
+ }
501
+ if (sub === 'status') {
502
+ const p = P.readPiece(dir); if (!p) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
503
+ for (const s of P.STAGES) console.log(`${s === p.stage ? '›' : ' '} ${s.padEnd(10)} ${p.stages[s].status}${p.stages[s].note ? ` “${p.stages[s].note}”` : ''}`);
504
+ for (const [d, x] of Object.entries(p.parts)) console.log(` ${d.padEnd(22)} ${x.status}${x.note ? ` “${x.note}”` : ''}`);
505
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
506
+ if (fresh.length) { console.log(`\n${fresh.length} unread:`); for (const e of fresh) console.log(P.eventLine(p, e)); }
507
+ break;
508
+ }
509
+ const stage = args._[3];
510
+ if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
511
+ if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working' }; }); console.log(`✓ ${stage}: working`); break; }
512
+ // review: hand the stage over and wait for the user's decision in the studio
513
+ P.toReview(dir, stage);
514
+ const s = await studio();
515
+ const url = s ? `${s.url}${q}` : null;
516
+ // the first hand-over opens the studio in the user's browser; after that the open page follows the piece
517
+ let opened = false;
518
+ if (url && !args['no-open'] && !P.readPiece(dir).opened) { opened = await studioLib.openInBrowser(url); if (opened) P.updatePiece(dir, (p) => { p.opened = true; }); }
519
+ console.log(`${stage} is ready for the user${url ? `: ${url}${opened ? ' (opened in their browser)' : ' (tell them to open it)'}` : ' (no studio: --no-studio)'}`);
520
+ if (args['no-wait']) { const r = P.unseen(dir); for (const e of r.events) console.log(P.eventLine(r.piece, e)); break; }
521
+ console.log('Waiting for their decision…');
522
+ const r = await P.awaitDecision(dir, stage, { timeout: Number(args.wait ?? 30) * 60 * 1000 });
523
+ for (const l of r.lines) console.log(l);
524
+ if (!r.done) { console.log(`(no decision yet: run piece review ${piece} ${stage} again, or piece status ${piece})`); process.exit(3); }
525
+ break;
526
+ }
527
+
528
+ case 'studio': {
529
+ const { startStudio, listPieces, findStudio, openInBrowser } = await import('../lib/studio.mjs');
530
+ const running = await findStudio(process.cwd());
531
+ if (args.stop) { if (running) { process.kill(running.pid); console.log(`stopped the studio on port ${running.port}`); } else console.log('no studio running for this project'); break; }
532
+ if (running) { console.log(`studio already running: ${running.url}`); if (args.open) await openInBrowser(running.url); break; }
533
+ const s = await startStudio({ port: Number(args.port ?? 4317) });
534
+ if (args.open) await openInBrowser(`http://127.0.0.1:${s.port}/`);
535
+ const list = listPieces(process.cwd());
536
+ console.log(`studio: http://127.0.0.1:${s.port}/${list.length === 1 ? `?p=${encodeURIComponent(list[0].piece)}` : ''} (${list.length} piece${list.length === 1 ? '' : 's'}; ctrl-c to stop)`);
537
+ await new Promise(() => {});
538
+ break;
539
+ }
540
+
420
541
  case 'wordmark': {
421
542
  await ensureDeps();
422
543
  const text = args._[1];
@@ -647,15 +768,15 @@ async function main() {
647
768
  const only = typeof args.only === 'string' ? args.only.split(',') : null;
648
769
  const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
649
770
  fs.mkdirSync(out, { recursive: true });
650
- const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe', 'shot'].includes(i.type) && (!only || only.includes(i.id)));
651
- const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5, shot: 1.8 };
771
+ const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe', 'shot', 'block'].includes(i.type) && (!only || only.includes(i.id)));
772
+ const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5, shot: 1.8, block: 2.5 };
652
773
  const demo = path.join(reg.dir, '_demo', 'index.html');
653
774
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
654
775
  try {
655
776
  for (const [k, it] of items.entries()) {
656
777
  process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
657
778
  const mp4 = path.join(tmp, `${it.id}.mp4`);
658
- if (it.type === 'recipe' || it.type === 'shot') {
779
+ if (it.type === 'recipe' || it.type === 'shot' || it.type === 'block') {
659
780
  // a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
660
781
  const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
661
782
  // the demo brand's copy goes in through the same generator `pamo add --set` uses (preview.values)
package/lib/chain.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // A chain: several blocks (or shots, recipes) rendered in the brand's look and joined into one piece.
2
+ //
3
+ // pieces/<piece>/chain.json what plays, how long, and how each part comes in
4
+ // pieces/<piece>/01-search-type/ each part is an ordinary installed scene: edit its CONFIG
5
+ //
6
+ // chain.json:
7
+ // { "title": "…", "format": "1920x1080", "join": "whip",
8
+ // "parts": [ { "id": "search-type", "dir": "01-search-type", "name": "Búsqueda", "seconds": 2.5 },
9
+ // { "id": "bar-grow", "dir": "02-bar-grow", "seconds": 3.2, "join": "dissolve", "from": 0 } ] }
10
+ //
11
+ // `join` on a part is how it comes in (the first part's is ignored); the chain's `join` is the default.
12
+ // cut a straight cut
13
+ // whip the frame slides out sideways with a motion smear and the next slides in (0.35 s, the default)
14
+ // dissolve a cross-fade (0.5 s)
15
+ // dip out to the brand's ground color and back in (0.5 s)
16
+ // Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
17
+ // land (the beat a block declares with B.cue(s, 'land', t)) gets a warning with the length that keeps it.
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { spawn, spawnSync } from 'node:child_process';
22
+
23
+ export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
24
+ const OVERLAP = new Set(['whip', 'dissolve']); // joins that need the outgoing part to run on under the next
25
+ const LAND_HOLD = 0.6; // seconds after a land for the hit to read
26
+
27
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
28
+ const pad = (i) => String(i + 1).padStart(2, '0');
29
+
30
+ /** Where a piece lives: a name (pieces/<name>) or a folder that holds chain.json. */
31
+ export function pieceDir(piece, cwd = process.cwd()) {
32
+ const asDir = path.resolve(cwd, piece);
33
+ if (fs.existsSync(path.join(asDir, 'chain.json')) || piece.includes(path.sep) || piece.startsWith('.')) return asDir;
34
+ return path.join(cwd, 'pieces', piece);
35
+ }
36
+
37
+ /** Parts from a structure (a board option): one part per beat, its block from the beat's id. */
38
+ export function partsFromStructure(st) {
39
+ return st.beats.map((b) => ({ id: b.id ?? null, name: b.name, seconds: +(b.to - b.from).toFixed(2), what: b.what }));
40
+ }
41
+
42
+ /** Parts from "search-type:2.5,browser-frame:3,bar-grow". */
43
+ export function partsFromList(list) {
44
+ return String(list).split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
45
+ const [id, sec] = x.split(':');
46
+ return { id, ...(sec ? { seconds: Number(sec) } : {}) };
47
+ });
48
+ }
49
+
50
+ /** A board's structure option, from a short id, a /b/<id> link or a long link (#fragment). */
51
+ export async function structureFromBoard(ref, option, { site }) {
52
+ const { decodeBoard } = await import('./live-board.js');
53
+ let board;
54
+ if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
55
+ else {
56
+ const id = String(ref).replace(/\/$/, '').split('/').pop();
57
+ const res = await fetch(`${site}/api/boards/${id}`);
58
+ if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${site} (expired, or a long link is needed)`);
59
+ board = (await res.json()).board;
60
+ }
61
+ const opts = board?.options ?? [];
62
+ // --option takes the letter from the reply line (D), or a structure's title; without it, the board's structure
63
+ const letter = option && /^[a-z]$/i.test(option) ? option.toUpperCase() : null;
64
+ const i = letter ? opts.findIndex((o, k) => (o.label ?? String.fromCharCode(65 + k)) === letter)
65
+ : option ? opts.findIndex((o) => o.structure?.title === option) : opts.findIndex((o) => o.structure);
66
+ const o = opts[i];
67
+ if (!o) throw new Error(`board: no option ${option ?? 'with a structure'} (it has ${opts.length})`);
68
+ if (o.structure) return { title: o.structure.title, parts: partsFromStructure(o.structure) };
69
+ return { title: board.title, parts: [{ id: o.id }] }; // a single block or recipe chosen: a chain of one
70
+ }
71
+
72
+ /**
73
+ * Installs each part as its own scene under pieces/<piece>/<nn>-<id>/ (in the requested format) and writes
74
+ * chain.json. Parts with no block (a beat the agent didn't name) are listed with id null for the agent to fill.
75
+ * Without parts, it syncs an existing chain.json: parts whose scene is missing are installed, the rest is kept.
76
+ */
77
+ export async function initChain({ cwd = process.cwd(), piece, title, parts, format, join, registry, force = false }) {
78
+ const { add, getItem, resolveRegistry } = await import('./registry.mjs');
79
+ const reg = resolveRegistry(registry);
80
+ const dir = pieceDir(piece, cwd), rel = path.relative(path.join(cwd, 'pieces'), dir), file = path.join(dir, 'chain.json');
81
+ const old = readJson(file, null);
82
+ if (old && parts && !force) throw new Error(`${path.relative(cwd, file)} exists: run init without --board/--parts to install parts you added, or --force to start over`);
83
+ if (!old && !parts) throw new Error(`no ${path.relative(cwd, file)} yet: give --board or --parts`);
84
+ const base = parts ? null : old;
85
+ format = format ?? base?.format ?? '1920x1080'; join = join ?? base?.join ?? 'whip';
86
+ if (!JOINS.hasOwnProperty(join)) throw new Error(`join "${join}": use ${Object.keys(JOINS).join(', ')}`);
87
+ const out = [], notes = [];
88
+ for (const [i, p] of (parts ?? old.parts).entries()) {
89
+ if (!p.id) { out.push({ ...p, id: null, dir: null }); notes.push(`part ${i + 1} "${p.name ?? '?'}" has no block yet: set its id in chain.json and run chain init ${piece} again`); continue; }
90
+ const sub = p.dir && p.dir.endsWith(`-${p.id}`) ? p.dir : `${pad(i)}-${p.id}`;
91
+ if (base && fs.existsSync(path.join(dir, sub, 'scene.html'))) { out.push({ ...p, dir: sub }); continue; } // installed already: your edits stay
92
+ const it = await getItem(p.id, reg);
93
+ if (it.install?.kind !== 'scene') throw new Error(`${p.id} is a ${it.type}, not a scene (chains take blocks, shots and recipes)`);
94
+ const hasFormat = (it.params ?? []).some((x) => x.name === 'format');
95
+ await add([p.id], { cwd, registry, force, name: path.join(rel, sub), set: hasFormat ? [`format=${format}`] : [] });
96
+ if (!hasFormat && format !== '1920x1080') notes.push(`${p.id} has no format param: it renders at its own size`);
97
+ const { id, name, seconds, what, dir: _, ...rest } = p; // rest: from, join, anything the agent added
98
+ out.push({ id, dir: sub, ...(name ? { name } : {}), ...(seconds ? { seconds } : {}), ...(what ? { what } : {}), ...rest });
99
+ }
100
+ const chain = { ...(base ?? {}), title: title ?? base?.title ?? piece, format, join, parts: out };
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ fs.writeFileSync(file, JSON.stringify(chain, null, 2) + '\n');
103
+ const { readPiece, updatePiece } = await import('./piece.mjs');
104
+ if (readPiece(dir)) updatePiece(dir, (p) => {
105
+ const keep = new Set(out.map((x) => x.dir).filter(Boolean));
106
+ for (const d of Object.keys(p.parts)) if (!keep.has(d)) delete p.parts[d];
107
+ for (const d of keep) p.parts[d] ??= { status: 'queued' };
108
+ });
109
+ return { dir, chain, notes };
110
+ }
111
+
112
+ function run(bin, argv) {
113
+ return new Promise((res, rej) => {
114
+ const p = spawn(bin, argv, { stdio: ['ignore', 'ignore', 'pipe'] });
115
+ let err = '';
116
+ p.stderr.on('data', (d) => { err += d; });
117
+ p.on('error', rej);
118
+ p.on('close', (c) => (c === 0 ? res() : rej(new Error(`${bin} failed (${c}): ${err.trim().split('\n').slice(-6).join('\n')}`))));
119
+ });
120
+ }
121
+ const FF = () => process.env.PAPER_MOTION_FFMPEG || 'ffmpeg';
122
+ function probe(file) {
123
+ const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=codec_type', '-of', 'json', file], { encoding: 'utf8' });
124
+ const j = JSON.parse(r.stdout || '{}');
125
+ return { duration: Number(j.format?.duration ?? 0), audio: (j.streams ?? []).some((s) => s.codec_type === 'audio') };
126
+ }
127
+
128
+ /** What a scene declares: its config and the gestures it marks (land, transition, text). */
129
+ async function sceneInfo(browser, url) {
130
+ const { openScene } = await import('./browser.mjs');
131
+ const s = await openScene(browser, url);
132
+ const marks = await s.page.evaluate(() => window.__pm.marks ?? []);
133
+ await s.close();
134
+ return { config: s.config, marks };
135
+ }
136
+
137
+ /** Checks a chain and works out each part's window, without rendering. */
138
+ export function plan(chain, infos) {
139
+ const warnings = [];
140
+ const parts = chain.parts.map((p, i) => {
141
+ const info = infos[i], D = info.config.duration, from = p.from ?? 0;
142
+ const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
143
+ if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
144
+ let seconds = p.seconds ?? D - from;
145
+ if (from + seconds > D + 1e-6) { warnings.push(`${p.dir}: ${seconds} s from ${from} runs past its end (${D} s); it plays ${+(D - from).toFixed(2)} s`); seconds = D - from; }
146
+ const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
147
+ const land = lands.length ? Math.max(...lands) : null;
148
+ if (land !== null && from + seconds < land + LAND_HOLD) warnings.push(`${p.dir}: cut at ${seconds} s loses its land at ${+(land - from).toFixed(2)} s; give it at least ${+(land - from + LAND_HOLD).toFixed(1)} s`);
149
+ return { ...p, from, seconds, join, D };
150
+ });
151
+ // the outgoing part runs on under an overlapping join, as far as it can
152
+ for (const [i, p] of parts.entries()) {
153
+ const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
154
+ p.to = Math.min(p.D, p.from + p.seconds + d);
155
+ }
156
+ return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
157
+ }
158
+
159
+ /**
160
+ * Renders every part in the brand's look and joins them into one file.
161
+ * Must run from the project root (where brand/ is): scenes load the brand from /brand.
162
+ */
163
+ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
164
+ if (draft) { dpr = 0.5; motionBlur = 1; }
165
+ return withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
166
+ const { renderVideo } = await import('./render.mjs');
167
+ for (const w of warnings) onStep({ warning: w });
168
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
169
+ try {
170
+ const files = [];
171
+ for (const [i, p] of parts.entries()) {
172
+ onStep({ part: i + 1, of: parts.length, dir: p.dir });
173
+ const f = path.join(tmp, `${pad(i)}.mov`);
174
+ await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
175
+ files.push(f);
176
+ }
177
+ const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
178
+ const ground = (tokens.colors?.ground?.hex ?? '#000000').replace('#', '0x');
179
+ const { width: W, height: H, fps } = config;
180
+ out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
181
+ fs.mkdirSync(path.dirname(out), { recursive: true });
182
+ onStep({ joining: true });
183
+ const j = await join({ files, parts, out, ground, fps, music: audio, lufs: sound.loudness ?? -16 });
184
+ return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
185
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Renders parts on their own, quickly (half resolution, no motion blur), to pieces/<piece>/drafts/<dir>.mp4: what
191
+ * the user reviews part by part in the studio. Skips approved parts unless named in `only`. Tracks status in piece.json.
192
+ */
193
+ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep = () => {} }) {
194
+ const { readPiece, updatePiece } = await import('./piece.mjs');
195
+ return withChain({ cwd, piece }, async ({ dir, url, parts, warnings }) => {
196
+ const { renderVideo } = await import('./render.mjs');
197
+ for (const w of warnings) onStep({ warning: w });
198
+ const tracked = !!readPiece(dir), mark = (d, patch) => tracked && updatePiece(dir, (p) => { p.parts[d] = { ...(p.parts[d] ?? {}), ...patch }; });
199
+ const todo = parts.filter((p) => (only?.length ? only.some((o) => p.dir === o || p.id === o || p.dir.startsWith(`${o}-`)) : (readPiece(dir)?.parts?.[p.dir]?.status ?? 'queued') !== 'approved'));
200
+ for (const p of todo) mark(p.dir, { status: 'queued' });
201
+ fs.mkdirSync(path.join(dir, 'drafts'), { recursive: true });
202
+ const done = [];
203
+ for (const [i, p] of todo.entries()) {
204
+ onStep({ part: i + 1, of: todo.length, dir: p.dir });
205
+ mark(p.dir, { status: 'rendering' });
206
+ const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
207
+ try {
208
+ await renderVideo({ url: url(p), out, from: p.from, to: p.from + p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
209
+ } catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
210
+ const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
211
+ mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
212
+ done.push(out);
213
+ }
214
+ return { rendered: done, warnings };
215
+ });
216
+ }
217
+
218
+ /** Plans a chain without rendering: each part's window, its join, and the warnings. */
219
+ export const checkChain = ({ cwd = process.cwd(), piece }) => withChain({ cwd, piece }, async (r) => r);
220
+
221
+ // Serves the project, opens each part once to read its size, duration and marks, and plans the chain.
222
+ async function withChain({ cwd, piece }, fn) {
223
+ const dir = pieceDir(piece, cwd), chain = readJson(path.join(dir, 'chain.json'), null);
224
+ if (!chain) throw new Error(`no chain.json in ${path.relative(cwd, dir) || '.'} (pamo chain init first)`);
225
+ if (!chain.parts?.length) throw new Error('chain.json has no parts');
226
+ const missing = chain.parts.filter((p) => !p.id || !p.dir || !fs.existsSync(path.join(dir, p.dir, 'scene.html')));
227
+ if (missing.length) throw new Error(`parts without a scene: ${missing.map((p) => p.name ?? p.id ?? '?').join(', ')} (set their id in chain.json and run chain init again)`);
228
+ if (!fs.existsSync(path.join(cwd, 'brand'))) throw new Error('no brand/ here: run chain from the project root');
229
+ const { startServer } = await import('./server.mjs');
230
+ const { launch } = await import('./browser.mjs');
231
+ const server = await startServer({ root: cwd });
232
+ const url = (p) => `http://127.0.0.1:${server.port}/${path.relative(cwd, path.join(dir, p.dir, 'scene.html')).split(path.sep).map(encodeURIComponent).join('/')}?render`;
233
+ try {
234
+ const browser = await launch(), infos = [];
235
+ try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
236
+ const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
237
+ if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
238
+ return await fn({ dir, chain, url, config: infos[0].config, ...plan(chain, infos) });
239
+ } finally { await server.close(); }
240
+ }
241
+
242
+ /** The ffmpeg graph: each part's join applied in turn, sound carried along, loudness set once at the end. */
243
+ async function join({ files, parts, out, ground, fps, music, lufs }) {
244
+ const lens = files.map((f) => probe(f));
245
+ const inputs = files.flatMap((f) => ['-i', f]);
246
+ const g = [];
247
+ // every part gets a sound track (silent if it declares none) so the audio chain lines up
248
+ const A = lens.map((l, i) => {
249
+ if (l.audio) { g.push(`[${i}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=0:${l.duration.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]`); return `[a${i}]`; }
250
+ g.push(`anullsrc=r=48000:cl=stereo,atrim=0:${l.duration.toFixed(3)}[a${i}]`); return `[a${i}]`;
251
+ });
252
+ // a dip fades the parts on either side into the ground color
253
+ const V = parts.map((p, i) => {
254
+ const f = [`fps=${fps}`, 'format=yuv444p10le', 'settb=AVTB', 'setpts=PTS-STARTPTS'];
255
+ const half = JOINS.dip / 2;
256
+ if (parts[i + 1]?.join === 'dip') f.push(`fade=t=out:st=${(lens[i].duration - half).toFixed(3)}:d=${half}:color=${ground}`);
257
+ if (p.join === 'dip') f.push(`fade=t=in:st=0:d=${half}:color=${ground}`);
258
+ g.push(`[${i}:v]${f.join(',')}[v${i}]`);
259
+ return `[v${i}]`;
260
+ });
261
+ let v = V[0], a = A[0], len = lens[0].duration;
262
+ const smears = [];
263
+ for (let i = 1; i < parts.length; i++) {
264
+ const j = parts[i].join, d = JOINS[j];
265
+ if (OVERLAP.has(j)) {
266
+ const off = Math.max(0, len - d);
267
+ g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
268
+ g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
269
+ if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
270
+ len = off + lens[i].duration;
271
+ } else {
272
+ g.push(`${v}${V[i]}concat=n=2:v=1:a=0[x${i}]`);
273
+ g.push(`${a}${A[i]}concat=n=2:v=0:a=1[y${i}]`);
274
+ len += lens[i].duration;
275
+ }
276
+ v = `[x${i}]`; a = `[y${i}]`;
277
+ }
278
+ // the whip's smear: a horizontal blur over the middle of each slide
279
+ if (smears.length) {
280
+ const on = smears.map(([s, e]) => `between(t\\,${s.toFixed(3)}\\,${e.toFixed(3)})`).join('+');
281
+ g.push(`${v}split[sa][sb];[sb]gblur=sigma=${48}:sigmaV=0.5[sc];[sa][sc]overlay=enable='${on}'[vs]`);
282
+ v = '[vs]';
283
+ }
284
+ const extra = [];
285
+ if (music) {
286
+ extra.push('-i', path.resolve(music));
287
+ const m = files.length;
288
+ g.push(`[${m}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=0:${len.toFixed(3)},afade=t=out:st=${Math.max(0, len - 1).toFixed(3)}:d=1[mu]`);
289
+ g.push(`${a}[mu]amix=inputs=2:normalize=0[am]`);
290
+ a = '[am]';
291
+ }
292
+ g.push(`${a}loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=48000[aout]`);
293
+ g.push(`${v}scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[vout]`);
294
+ await run(FF(), ['-hide_banner', '-y', ...inputs, ...extra, '-filter_complex', g.join(';'), '-map', '[vout]', '-map', '[aout]',
295
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-x264-params', 'aq-mode=3', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
296
+ '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', '-t', len.toFixed(3), out]);
297
+ return { out, seconds: len };
298
+ }
package/lib/customize.mjs CHANGED
@@ -138,6 +138,9 @@ function fontTokens(item, values) {
138
138
  }
139
139
 
140
140
  /** A recipe's CONFIG object from flat values (proof1Title… → proof[0].title, "1920x1080" → [1920, 1080]). */
141
+ // resources whose file is a whole scene, customized through its CONFIG
142
+ const SCENE_TYPES = ['recipe', 'shot', 'block'];
143
+
141
144
  function recipeConfig(values) {
142
145
  const cfg = {}, groups = {};
143
146
  for (const [k, v] of Object.entries(values)) {
@@ -182,7 +185,7 @@ export function apply(item, values) {
182
185
  const files = (item.files ?? []).map((f) => {
183
186
  if (f.content === undefined) return f;
184
187
  if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
185
- if ((item.type === 'recipe' || item.type === 'shot') && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
188
+ if (SCENE_TYPES.includes(item.type) && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
186
189
  return f;
187
190
  });
188
191
  let entry = item.install?.entry;
@@ -227,7 +230,7 @@ export function code(item, values, { showAll = false, site = 'https://paper-moti
227
230
  { key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
228
231
  { key: 'css', label: 'CSS variables', lang: 'css', code: `${Object.values(f).map((x) => `@import url('${x.css}');`).filter((x, i, a) => a.indexOf(x) === i).join('\n')}\n\n:root {\n${Object.entries(f).map(([r, x]) => ` --font-${r}: '${x.family}', ${cat(x.family)};`).join('\n')}\n --display-weight: ${e.type.display.weight};\n --display-tracking: ${e.type.display.tracking};\n}` },
229
232
  ];
230
- } else if (item.type === 'recipe' || item.type === 'shot') {
233
+ } else if (SCENE_TYPES.includes(item.type)) {
231
234
  const scene = out.files.find((f) => /scene\.html$/.test(f.path));
232
235
  tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
233
236
  }
package/lib/live-board.js CHANGED
@@ -9,15 +9,16 @@
9
9
  export const BOARD_VERSION = 1;
10
10
  export const MAX_PAYLOAD = 8 * 1024;
11
11
  const LABELS = ['A', 'B', 'C', 'D'];
12
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
12
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot', 'block'];
13
13
 
14
14
  /**
15
15
  * @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
16
16
  * look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
17
17
  * choose?: number | [number, number], // how many options the user may pick: 1 (default), 3, or [min, max]
18
18
  * options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string,
19
- * structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string }> } }> }} Board
19
+ * structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string, id?: string }> } }> }} Board
20
20
  * An option with `structure` (recipe boards only) is a structure the agent proposes for this brief instead of a recipe.
21
+ * A beat's `id` names the block or shot that shows it, so the board plays that clip for the beat instead of a sketch.
21
22
  */
22
23
 
23
24
  /** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
@@ -43,6 +44,7 @@ export function validateBoard(b, index) {
43
44
  if (!(Number(st.duration) > 0)) errs.push(`${at}.structure.duration (seconds) is required`);
44
45
  if (!Array.isArray(st.beats) || st.beats.length < 2 || st.beats.length > 8) errs.push(`${at}.structure.beats: 2 to 8 beats`);
45
46
  for (const [j, bt] of (st.beats ?? []).entries()) if (!bt?.name || typeof bt.from !== 'number' || typeof bt.to !== 'number' || bt.to <= bt.from || !bt.what) errs.push(`${at}.structure.beats[${j}] needs name, from < to (seconds) and what`);
47
+ for (const [j, bt] of (st.beats ?? []).entries()) if (bt?.id != null && (typeof bt.id !== 'string' || (index && !index[bt.id]))) errs.push(`${at}.structure.beats[${j}].id "${bt.id}" is not in the registry`);
46
48
  } else if (!o.id) errs.push(`${at}.id is required`);
47
49
  else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
48
50
  } else {