paper-motion 0.4.1 → 0.5.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 (52) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/bin/pamo.mjs +62 -5
  3. package/lib/edit.mjs +31 -3
  4. package/lib/piece.mjs +121 -10
  5. package/lib/registry.mjs +10 -4
  6. package/lib/studio.mjs +195 -7
  7. package/package.json +2 -1
  8. package/runtime/brand.js +14 -2
  9. package/runtime/player.js +4 -2
  10. package/studio/index.html +236 -57
  11. package/examples/breath/stills/contact-sheet.jpg +0 -0
  12. package/examples/breath/stills/t000.00.png +0 -0
  13. package/examples/breath/stills/t002.00.png +0 -0
  14. package/examples/breath/stills/t004.00.png +0 -0
  15. package/examples/breath/stills/t006.00.png +0 -0
  16. package/examples/breath/stills/t008.00.png +0 -0
  17. package/examples/breath/stills/t010.00.png +0 -0
  18. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  19. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  20. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  21. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  22. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  23. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  24. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  25. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  26. package/examples/halo-explainer/stills/t000.00.png +0 -0
  27. package/examples/halo-explainer/stills/t006.00.png +0 -0
  28. package/examples/halo-explainer/stills/t012.00.png +0 -0
  29. package/examples/halo-explainer/stills/t018.00.png +0 -0
  30. package/examples/halo-explainer/stills/t024.00.png +0 -0
  31. package/examples/halo-explainer/stills/t030.00.png +0 -0
  32. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  33. package/examples/install-loop/stills/t000.00.png +0 -0
  34. package/examples/install-loop/stills/t001.00.png +0 -0
  35. package/examples/install-loop/stills/t002.00.png +0 -0
  36. package/examples/install-loop/stills/t003.00.png +0 -0
  37. package/examples/install-loop/stills/t004.00.png +0 -0
  38. package/examples/install-loop/stills/t005.00.png +0 -0
  39. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  40. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  41. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  42. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  43. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  44. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  45. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  46. package/examples/signal/stills/contact-sheet.jpg +0 -0
  47. package/examples/signal/stills/t000.00.png +0 -0
  48. package/examples/signal/stills/t001.67.png +0 -0
  49. package/examples/signal/stills/t003.33.png +0 -0
  50. package/examples/signal/stills/t005.00.png +0 -0
  51. package/examples/signal/stills/t006.67.png +0 -0
  52. package/examples/signal/stills/t008.33.png +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,84 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
+ ## 0.5.0 · 2026-11-13
6
+
7
+ **Studio v2: one page, four steps, an honest agent.** The user makes a piece in the studio alone (What you want to
8
+ say, How it looks, Your video, Export); the agent listens in the background, and its visual choices come into the
9
+ studio instead of links. Five phases, one end-to-end check (`scripts/check-studio.mjs`, 53 checks).
10
+
11
+ Studio v2, phase 1 ([spec](../../docs/spec-studio-v2.md), [plan](../../docs/plan-studio-v2.md)): an honest connection with the agent.
12
+
13
+ - **`piece listen <piece>`**: the agent keeps it running in the background between turns. It returns as soon as the
14
+ user does something that needs the agent (a message, an answer, an approval, a choice), printing it as lines and
15
+ marking them read, or exits 3 after 9 minutes with nothing. The user no longer has to go to the chat to say
16
+ "continue".
17
+ - **The studio only says what it can check about the agent:** listening (a command of the agent is waiting on the
18
+ piece, and its process is alive), working on something (said with `piece working … --on "…"`, for up to 20
19
+ minutes), asking you, or not connected. "Your agent waits" and "tell your agent continue" are gone. A command
20
+ that was killed stops showing as listening at once.
21
+ - **Questions are answered in the studio.** `piece ask` shows the question with an answer box; the answer reaches
22
+ `piece listen` as an `answered "…": "…"` line. **Answered in the chat** closes it.
23
+ - **Ask your agent is a thread:** each message shows Sent, then Read with the time; `piece say <piece> "…"` puts the
24
+ agent's reply there. A message no longer moves the stage to "changes".
25
+ - `scripts/check-studio.mjs` drives a fresh project end to end (headless Chrome for the user, real `pamo` commands
26
+ for the agent).
27
+
28
+ Studio v2, phase 2: one page, four steps, and How it looks.
29
+
30
+ - **A new stage, `look`**, between `inventory` and `video` (`STAGES`: brief, inventory, look, video, final). A
31
+ project whose brand already settles colours, type and a background starts with it done; older pieces get it as
32
+ approved.
33
+ - **The stepper:** What you want to say · How it looks · Your video · Export. Steps ahead stay locked until reached;
34
+ earlier ones reopen. Style moved into How it looks.
35
+ - **How it looks:** colours, type, background and movement (pace, and how words arrive), each a still card. Pointing
36
+ at one plays it in a single live preview of the user's own first sentence (`line-in`, with the look laid over the
37
+ brand only for that preview: `?embed&look=…`, read by `runtime/brand.js`). Choosing writes `brand/`: the palette
38
+ (the brand's library backgrounds take its colours again), the font pair, a colour by hand, the brand's main
39
+ background (added from the library in its colours when needed), pace and the default entrance
40
+ (`brand/motion.json` `entrance`, which `line-in` uses when a part sets none). **Make my video** builds the first
41
+ version, which used to happen on approving the content.
42
+ - **A still of every background** (`apps/web/public/previews/<id>.jpg`, from `scripts/render-backgrounds.mjs`), so
43
+ cards never run a shader.
44
+
45
+ Studio v2, phase 3: the agent's choices come into the studio ([ADR 0005](../../docs/adr/0005-the-studio-replaces-board-links-during-a-piece.md)).
46
+
47
+ - With a piece open and its studio running, **`pamo board --url …` puts the options into the studio** instead of a
48
+ link, on the step they decide: palettes, font pairs, backgrounds and moves in How it looks (kits as a whole look
49
+ at its top), transitions, structures and blocks in Your video. The step shows a dot; the options come first in
50
+ their section, marked **Suggested by your agent**, with the agent's question and notes, and play in the preview
51
+ when pointed at. The command waits (the studio shows the agent listening) and prints the same reply line, built
52
+ with `replyLine`. **None of these** sends what the user would rather see. `--link` makes a link anyway.
53
+ - A palette, font pair or background picked there is applied to `brand/` at once, like any card; moves,
54
+ transitions and structures go to the agent as the line.
55
+ - `board --await c1` waits again for a studio choice, `board --forget c1` takes it out.
56
+
57
+ Studio v2, phase 4: Your video as scenes.
58
+
59
+ - **A list of scenes instead of the strip:** each with its number, a still of itself, what it says and how long it
60
+ plays; drag to reorder; **Add a scene** at the end asks what it is for.
61
+ - **Stills of the real scenes** (the user's words, brand and settings), rendered by the studio one at a time after a
62
+ scene changes (`pieces/<piece>/.stills/`), never while a final renders; until then the library's still shows.
63
+ - **Five tabs:** Text, Animation (with the block's own settings), Background, Speed (length is speed) and Next scene
64
+ (the transition, chosen by watching, or the same between every scene).
65
+ - **Nothing rebuilds by itself.** Something new kept in What you want to say offers **Add it to the video**; approving
66
+ the content again never takes a built piece back to How it looks.
67
+ - **The playing scene no longer restarts when the page redraws** (a still arriving, the agent's state changing): it is
68
+ moved without reloading (`moveBefore`, where the browser has it), and a still only updates its thumbnail.
69
+
70
+ Studio v2, phase 5: light on the machine.
71
+
72
+ - An embedded preview that ended stops its frame loop (a click starts it again): an idle studio draws nothing. The
73
+ check measures it: 0 shader draws and 0 animation frames for 5 s after a scene ends, and at most one live preview
74
+ on the page.
75
+ - **Fixed for npm installs:** the site's `registry.json` lists params by name only, so an installed studio had no
76
+ block settings or entrances to offer (since 0.4) and, now, no palettes' colours, font pairs or backgrounds. It
77
+ fetches the full resource of those (`/r/<id>.json`) when it starts. `scripts/check-studio.mjs` runs against an
78
+ installed package with `PAMO_BIN=…`.
79
+ - The site's making-of, hero and docs page tell the four steps, the five stages behind them, `piece listen` and the
80
+ choices in the studio.
81
+
82
+
5
83
  ## 0.4.1 · 2026-11-12
6
84
 
7
85
  - **The studio's live previews are much lighter.** A scene opened with `?embed` draws its shaders at the size it is
package/bin/pamo.mjs CHANGED
@@ -364,7 +364,7 @@ async function main() {
364
364
  const { parseSet, setArgs } = await import('../lib/customize.mjs');
365
365
  const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
366
366
  const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
367
- if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\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, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
367
+ if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url] [--link] (with a piece open and its studio running the options go into the studio, not a link; --link makes a link anyway)\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\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, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
368
368
  const LABELS = ['A', 'B', 'C', 'D'];
369
369
  // commas only separate at the top level (not inside [..], {..} or quotes), and only before a new "key=":
370
370
  // so ramp=["#000","#111"] and multi-value colors survive
@@ -420,6 +420,31 @@ async function main() {
420
420
  } else if (typeof args.look === 'string') for (const spec of splitLook(args.look)) { const o = await parseOne(spec); 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 (or "brand"), not "${o.id}"`); }
421
421
  const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
422
422
  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 } : {}), ...(choose !== undefined ? { choose } : {}), options };
423
+ // a piece open with its studio running: the options go into the studio, on the step they decide, not into a
424
+ // link; the command waits there and prints the same reply line (ADR 0005). --link makes a site link anyway.
425
+ if (!args.link) {
426
+ const PCs = await import('../lib/piece.mjs');
427
+ const pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
428
+ const st = pdirS ? await (await import('../lib/studio.mjs')).findStudio(process.cwd()) : null;
429
+ if (st) {
430
+ const { validateBoard } = await import('../lib/live-board.js');
431
+ const errs = validateBoard(board, index); if (errs.length) { console.error(`✗ ${errs.join('; ')}`); process.exit(1); }
432
+ const type = board.kind === 'kit' ? 'kit' : board.options[0].structure ? 'structure' : index[board.options[0].id]?.type;
433
+ const PLACE = { palette: ['look', 'colours', 'How it looks · Colours'], 'font-pair': ['look', 'type', 'How it looks · Type'], background: ['look', 'background', 'How it looks · Background'],
434
+ text: ['look', 'movement', 'How it looks · Movement'], component: ['look', 'movement', 'How it looks · Movement'], camera: ['look', 'movement', 'How it looks · Movement'], kit: ['look', 'kit', 'How it looks'],
435
+ transition: ['video', 'joins', 'Your video'], structure: ['video', 'structure', 'Your video'], recipe: ['video', 'structure', 'Your video'], block: ['video', 'scenes', 'Your video'], shot: ['video', 'scenes', 'Your video'] };
436
+ const [step, section, label] = PLACE[type] ?? ['video', 'other', 'Your video'];
437
+ const cid = PCs.addChoice(pdirS, { board, where: { step, section, type } });
438
+ console.log(`in the studio, on ${label}: ${st.url}?p=${encodeURIComponent(path.basename(pdirS))} (choice ${cid}; no link to share)`);
439
+ if (args['no-wait']) { console.error(`the user chooses in the studio; wait for it with: board --await ${cid}`); break; }
440
+ const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
441
+ console.error(`waiting up to ${minutes} min for their choice in the studio…`);
442
+ const line = await PCs.awaitChoiceIn(pdirS, cid, { timeout: minutes * 60 * 1000 });
443
+ if (!line) { console.error(`no choice yet: wait again with board --await ${cid}`); process.exit(3); }
444
+ console.log(line);
445
+ break;
446
+ }
447
+ }
423
448
  const site = typeof args.site === 'string' ? args.site : SITE;
424
449
  let frag, short = null;
425
450
  try { frag = await encodeBoard(board, index); } catch (e) { console.error(e.message); process.exit(1); }
@@ -443,6 +468,22 @@ async function main() {
443
468
  } else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
444
469
  break;
445
470
  }
471
+ if (args.await && /^c\d+$/.test(String(args.await))) { // a choice in the studio
472
+ const PCs = await import('../lib/piece.mjs'), pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
473
+ if (!pdirS) { console.error('no open piece here (pass --piece)'); process.exit(1); }
474
+ const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
475
+ console.error(`waiting up to ${minutes} min for their choice in the studio…`);
476
+ const line = await PCs.awaitChoiceIn(pdirS, String(args.await), { timeout: minutes * 60 * 1000 });
477
+ if (!line) { console.error('no choice yet: run it again'); process.exit(3); }
478
+ console.log(line);
479
+ break;
480
+ }
481
+ if (args.forget && /^c\d+$/.test(String(args.forget))) {
482
+ const PCs = await import('../lib/piece.mjs'), pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
483
+ if (!pdirS) { console.error('no open piece here (pass --piece)'); process.exit(1); }
484
+ PCs.forgetChoice(pdirS, String(args.forget)); console.log(`took choice ${args.forget} out of the studio`);
485
+ break;
486
+ }
446
487
  if (args.await) {
447
488
  // wait (again) for the choice on a board already shared: for shells that cap how long one command may run
448
489
  const { awaitChoice } = await import('../lib/live-board.js');
@@ -657,7 +698,7 @@ async function main() {
657
698
 
658
699
  case 'piece': {
659
700
  const sub = args._[1], piece = args._[2];
660
- if (!['init', 'review', 'working', 'status', 'ask'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece working <piece> <stage>\n piece ask <piece> "the question you are asking in the chat" | --done\n piece status <piece>\n ask: run it right before you ask the user anything in the chat, so the studio sends them there (the next review or working clears it, or --done). --return <url> on any of these gives the studio a link back to you when you run in a browser.\n Stages: brief, inventory (what the video can show), video (they edit it in the studio), final. review hands a stage to the user in the studio and prints what they did, one line each; it refuses while an earlier stage is not approved (--skip moves on without it, only when the user agreed). --no-wait returns at once with what is unread; --no-open does not open their browser; --no-studio runs without the studio.'); process.exit(args.help ? 0 : 1); }
701
+ if (!['init', 'review', 'working', 'status', 'ask', 'listen', 'say'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece listen <piece> [--wait 9 (minutes)]\n piece working <piece> <stage> [--on "what you are doing, in plain words"]\n piece say <piece> "a short reply the user reads in the studio"\n piece ask <piece> "the question" | --done\n piece status <piece>\n listen: run it in the background between your turns; it returns as soon as the user does something that needs you (a message, an answer, an approval, a choice), printing it as lines, or after --wait minutes with nothing (exit 3: start it again). While it runs the studio says you are listening.\n ask: run it right before you ask the user anything in the chat, so the studio sends them there (the next review or working clears it, or --done). --return <url> on any of these gives the studio a link back to you when you run in a browser.\n Stages: brief, inventory (what the video can show), video (they edit it in the studio), final. review hands a stage to the user in the studio and prints what they did, one line each; it refuses while an earlier stage is not approved (--skip moves on without it, only when the user agreed). --no-wait returns at once with what is unread; --no-open does not open their browser; --no-studio runs without the studio.'); process.exit(args.help ? 0 : 1); }
661
702
  const P = await import('../lib/piece.mjs');
662
703
  const { pieceDir } = await import('../lib/chain.mjs');
663
704
  const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
@@ -689,15 +730,31 @@ async function main() {
689
730
  await host();
690
731
  if (args.done) { P.clearAsk(dir); console.log('✓ cleared: the studio no longer sends the user to the chat'); break; }
691
732
  const question = typeof args._[3] === 'string' ? args._[3].trim() : '';
692
- if (!question) { console.error('piece ask: give the question in one line (the one you are asking in the chat), or --done'); process.exit(1); }
733
+ if (!question) { console.error('piece ask: give the question in one line, or --done'); process.exit(1); }
693
734
  P.setAsk(dir, question);
694
- console.log(`✓ the studio now tells the user you asked this in the chat${P.readPiece(dir).agent ? ', with a way back to you' : ''}. Ask it now; the next piece review or piece working clears it (or piece ask ${piece} --done).`);
735
+ console.log(`✓ the studio shows your question with a box to answer it; their answer reaches piece listen ${piece} as an "answered" line. Ask it in the chat too. It closes when they answer, or with the next piece review or piece working (or piece ask ${piece} --done).`);
736
+ break;
737
+ }
738
+ if (sub === 'listen') {
739
+ if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
740
+ await host();
741
+ const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 9;
742
+ const r = await P.listenFor(dir, { timeout: minutes * 60 * 1000 });
743
+ for (const l of r.lines) console.log(l);
744
+ if (!r.done) { console.log(`(nothing from the user in ${minutes} min: run piece listen ${piece} again)`); process.exit(3); }
745
+ break;
746
+ }
747
+ if (sub === 'say') {
748
+ if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
749
+ const text = typeof args._[3] === 'string' ? args._[3].trim() : '';
750
+ if (!text) { console.error('piece say: give the reply in one or two plain sentences'); process.exit(1); }
751
+ P.addNote(dir, text); console.log('✓ the user sees it in the studio, under Ask your agent');
695
752
  break;
696
753
  }
697
754
  const stage = args._[3];
698
755
  await host();
699
756
  if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
700
- if (sub === 'working') { P.updatePiece(dir, (p) => { delete p.ask; p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working', since: new Date().toISOString() }; }); console.log(`✓ ${stage}: working`); break; }
757
+ if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working', since: new Date().toISOString() }; }); P.setWorking(dir, typeof args.on === 'string' ? args.on : null); console.log(`✓ ${stage}: working${typeof args.on === 'string' ? ` on ${args.on}` : ''} (the studio shows it until you listen or review, or for 20 minutes)`); break; }
701
758
  // review: hand the stage over and wait for the user's decision in the studio
702
759
  if (stage === 'inventory') {
703
760
  // the user is asked whether each thing is true and worth showing: they cannot judge half an item
package/lib/edit.mjs CHANGED
@@ -320,19 +320,47 @@ export async function ensureLook(cwd, name) {
320
320
  return installed;
321
321
  }
322
322
 
323
+ // the brand's backgrounds that come from the library take its colours again (after the palette or a colour changed)
324
+ async function recolorBackgrounds(cwd) {
325
+ const setFile = path.join(cwd, 'brand/shader-set/set.json'), set = readJson(setFile, {});
326
+ const { add, getIndex } = await import('./registry.mjs');
327
+ const index = new Set(((await getIndex().catch(() => ({ items: [] }))).items ?? []).filter((x) => x.type === 'background').map((x) => x.id));
328
+ const ids = (set.entries ?? []).filter((e) => e.shader && !e.image && index.has(e.id)).map((e) => e.id);
329
+ if (!ids.length) return;
330
+ const order = (set.entries ?? []).map((e) => e.id);
331
+ await add(ids, { cwd, palette: 'brand', force: true });
332
+ const after = readJson(setFile, {}); // keep the order the user had: the first is the brand's background
333
+ after.entries = [...(after.entries ?? [])].sort((x, y) => (order.indexOf(x.id) + 1 || 1e9) - (order.indexOf(y.id) + 1 || 1e9));
334
+ writeJson(setFile, after);
335
+ }
336
+
323
337
  export async function editStyle(cwd, dir, a) {
324
- const tokens = path.join(cwd, 'brand/tokens.json'), motion = path.join(cwd, 'brand/motion.json');
325
- snapshot(cwd, dir, [tokens, motion, path.join(cwd, 'brand/fonts.css')], 'the style');
338
+ const tokens = path.join(cwd, 'brand/tokens.json'), motion = path.join(cwd, 'brand/motion.json'), setFile = path.join(cwd, 'brand/shader-set/set.json');
339
+ snapshot(cwd, dir, [tokens, motion, path.join(cwd, 'brand/fonts.css'), setFile], 'the style');
326
340
  if (a.type === 'palette' || a.type === 'font') {
327
341
  const { add } = await import('./registry.mjs');
328
342
  await add([a.value], { cwd, force: true });
343
+ if (a.type === 'palette') await recolorBackgrounds(cwd);
329
344
  return { say: `${a.type === 'palette' ? 'Colours' : 'Type'}: ${a.value}` };
330
345
  }
331
346
  if (a.type === 'color') {
332
347
  if (!/^#[0-9a-f]{6}$/i.test(a.value) || !['ground', 'figure', 'accent'].includes(a.key)) throw new Error('bad colour');
333
- const t = readJson(tokens, {}); t.colors ??= {}; t.colors[a.key] = { ...(t.colors[a.key] ?? {}), hex: a.value.toUpperCase() }; writeJson(tokens, t);
348
+ const t = readJson(tokens, {}); t.colors ??= {}; t.colors[a.key] = { ...(t.colors[a.key] ?? {}), hex: a.value.toUpperCase() }; delete t.ramp; writeJson(tokens, t);
349
+ await recolorBackgrounds(cwd);
334
350
  return { say: `${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[a.key]} colour ${a.value}` };
335
351
  }
352
+ if (a.type === 'background') { // the brand's background: the first entry of its set, added from the library in its colours
353
+ let set = readJson(setFile, { entries: [] });
354
+ if (!(set.entries ?? []).some((e) => e.id === a.value)) { const { add } = await import('./registry.mjs'); await add([a.value], { cwd, palette: 'brand' }); set = readJson(setFile, { entries: [] }); }
355
+ const i = set.entries.findIndex((e) => e.id === a.value); if (i < 0) throw new Error('no such background');
356
+ set.entries.unshift(...set.entries.splice(i, 1)); writeJson(setFile, set);
357
+ return { say: `Background: ${a.value}` };
358
+ }
359
+ if (a.type === 'entrance') { // how words arrive, unless a part says otherwise
360
+ if (!/^[a-z-]+$/.test(a.value)) throw new Error('bad entrance');
361
+ const m = readJson(motion, {}); m.entrance = a.value; writeJson(motion, m);
362
+ return { say: `Words arrive: ${a.value}` };
363
+ }
336
364
  if (a.type === 'pace') {
337
365
  if (!['calm', 'medium', 'fast'].includes(a.value)) throw new Error('bad pace');
338
366
  const m = readJson(motion, {}); m.pace = a.value; writeJson(motion, m);
package/lib/piece.mjs CHANGED
@@ -1,12 +1,17 @@
1
1
  // A piece's progress: pieces/<piece>/piece.json, shared by the agent (CLI) and the user (pamo studio).
2
2
  //
3
- // stage the stage the piece is at: brief → inventory → video → final. The user sees three steps (What it says,
4
- // Your video, Export); the brief is settled in the chat.
3
+ // stage the stage the piece is at: brief → inventory → look → video → final. The user sees four steps (What
4
+ // you want to say: the brief and the inventory; How it looks; Your video; Export).
5
5
  // stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
6
6
  // brief what, who, format, tone, action: written by the agent, editable by the user
7
7
  // parts per part (by its folder in chain.json): status (queued · rendering · review · changes · approved), note
8
- // events what the user did in the studio, in order; `seen` is the last one the agent has read
9
- // ask a question the agent asked in the chat and is waiting on (the studio sends the user there)
8
+ // events what the user did in the studio, in order; `seen` is the last one the agent has read, and each
9
+ // event the agent read carries `readAt`
10
+ // ask a question the agent asked and is waiting on; the user answers it in the studio (an `answer` event)
11
+ // working what the agent said it is doing (`piece working --on`) and since when; it lapses after 20 minutes
12
+ // notes the agent's short replies to the user, shown in the studio's thread (`piece say`)
13
+ // choices options the agent put into the studio (`pamo board` while a piece is open), where they show, and
14
+ // the reply line once the user chose (ADR 0005)
10
15
  // agent the app or page the agent runs in, so the studio can take the user back to it
11
16
  //
12
17
  // chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
@@ -14,7 +19,7 @@ import fs from 'node:fs';
14
19
  import path from 'node:path';
15
20
  import { pieceDir } from './chain.mjs';
16
21
 
17
- export const STAGES = ['brief', 'inventory', 'video', 'final'];
22
+ export const STAGES = ['brief', 'inventory', 'look', 'video', 'final'];
18
23
  // the stages a piece had before Your video took them over: a piece at one of them is at `video` now
19
24
  const OLD_VIDEO = ['look', 'structure', 'parts', 'assembly'];
20
25
  export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
@@ -39,6 +44,13 @@ function upgrade(p) {
39
44
  return p;
40
45
  }
41
46
 
47
+ /** A project whose brand already settles colours, type and a background: How it looks starts done. */
48
+ export function brandSettled(cwd) {
49
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), 'utf8')); } catch { return {}; } };
50
+ const t = read('brand/tokens.json'), set = read('brand/shader-set/set.json');
51
+ return !!(t.colors?.ground && t.colors?.figure && t.fonts?.display && (Array.isArray(set) ? set : set.entries ?? []).some((e) => e.shader && !e.image));
52
+ }
53
+
42
54
  /** Writes atomically (the studio and the CLI both write it). */
43
55
  export function writePiece(dir, p) {
44
56
  fs.mkdirSync(dir, { recursive: true });
@@ -67,7 +79,7 @@ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force
67
79
  return writePiece(dir, {
68
80
  v: 1, piece: path.basename(dir), title: title ?? path.basename(dir), brand: tokens.brand ?? null,
69
81
  stage: 'brief',
70
- stages: Object.fromEntries(STAGES.map((s, i) => [s, { status: i ? 'pending' : 'working' }])),
82
+ stages: Object.fromEntries(STAGES.map((s, i) => [s, s === 'look' && brandSettled(cwd) ? { status: 'approved', brand: true } : { status: i ? 'pending' : 'working' }])),
71
83
  brief: Object.fromEntries(BRIEF_KEYS.map((k) => [k, brief[k] ?? ''])),
72
84
  parts: {}, events: [], seen: 0, created: now(), updated: now(),
73
85
  });
@@ -75,12 +87,14 @@ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force
75
87
 
76
88
  /** The line the agent reads for an event (same family as the board reply). */
77
89
  export function eventLine(p, e) {
78
- const who = e.part ? `part ${e.part}` : e.stage;
90
+ const who = e.part ? `part ${e.part}` : e.stage ?? '';
79
91
  const what = e.kind === 'approve' ? 'approved'
80
92
  : e.kind === 'changes' ? `changes: "${e.text}"`
81
93
  : e.kind === 'order' ? `order ${e.text}`
82
94
  : e.kind === 'reopen' ? 'reopened'
83
95
  : e.kind === 'failed' ? `could not join the parts: "${e.text}"`
96
+ : e.kind === 'answer' ? `answered${e.q ? ` "${e.q}"` : ''}: "${e.text}"`
97
+ : e.kind === 'choice' ? `chose${e.title ? ` on "${e.title}"` : ''}: ${e.text}`
84
98
  : e.say ? e.say : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
85
99
  // the studio joins the draft again by itself after an edit by hand; the full-quality render stays the agent's
86
100
  const tail = `${e.rejoined ? ' (the studio is joining the draft again)' : ''}${e.rejoin ? ' · join again: pamo chain --draft' : ''}${e.refinal ? ` · the final is out of date: render it again (pamo chain ${p.piece} --motion-blur 5), then review final` : ''}`;
@@ -105,6 +119,7 @@ export function toReview(dir, stage, { skip = false } = {}) {
105
119
  if (open.length && !skip) throw new Error(`${open.join(', ')} not approved yet: review ${open.length > 1 ? 'them' : 'it'} first (piece review ${p.piece} ${open[0]}), or pass --skip when the user agreed to move on without ${open.length > 1 ? 'them' : 'it'}`);
106
120
  for (const s of open) p.stages[s] = { ...p.stages[s], status: 'approved' };
107
121
  delete p.ask; // handing a stage over means the question in the chat is settled
122
+ delete p.working;
108
123
  p.stage = stage;
109
124
  p.stages[stage] = { status: 'review', since: now() };
110
125
  });
@@ -116,10 +131,55 @@ export const earlierOpen = (p, stage) => STAGES.slice(0, STAGES.indexOf(stage)).
116
131
  /** Events the agent hasn't read; marks them read when `mark`. */
117
132
  export function unseen(dir, { mark = true } = {}) {
118
133
  let out = [];
119
- const p = updatePiece(dir, (q) => { out = q.events.filter((e) => e.n > (q.seen ?? 0)); if (mark && out.length) q.seen = out.at(-1).n; });
134
+ const p = updatePiece(dir, (q) => {
135
+ out = q.events.filter((e) => e.n > (q.seen ?? 0));
136
+ if (mark && out.length) { const at = now(); for (const e of out) e.readAt = at; q.seen = out.at(-1).n; }
137
+ });
120
138
  return { piece: p, events: out };
121
139
  }
122
140
 
141
+ // what the user does that needs the agent: something asked, answered, approved, reopened, chosen, or a join that failed
142
+ const needsAgent = (e) => ['changes', 'answer', 'reopen', 'choice', 'failed'].includes(e.kind) || (e.kind === 'approve' && !e.part) || e.rejoin || e.refinal;
143
+
144
+ /**
145
+ * Listens on the piece between the agent's turns (`pamo piece listen`): returns as soon as the user does something
146
+ * that needs the agent, with every unread event as lines (and marks them read), or after `timeout` with nothing.
147
+ * While it listens the studio says so; it is the agent's way to hear the studio without the user going to the chat.
148
+ */
149
+ export async function listenFor(dir, { timeout = 9 * 60 * 1000, every = 1000, signal } = {}) {
150
+ const t0 = Date.now();
151
+ let last = 0;
152
+ updatePiece(dir, (p) => { delete p.working; }); // listening means it is not doing anything else
153
+ // interrupted (the agent cancels it, Ctrl+C): stop saying it listens right away
154
+ const stop = () => { unbeat(dir); process.exit(130); };
155
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
156
+ try {
157
+ for (;;) {
158
+ if (Date.now() - last > 3000) { beat(dir, { waiting: 'listen' }); last = Date.now(); }
159
+ const p = readPiece(dir);
160
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
161
+ if (fresh.some(needsAgent)) { const r = unseen(dir); return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) }; }
162
+ if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: [], lines: [] };
163
+ await new Promise((res) => setTimeout(res, every));
164
+ }
165
+ } finally { unbeat(dir); process.off('SIGINT', stop); process.off('SIGTERM', stop); }
166
+ }
167
+
168
+ const WORKING_FOR = 20 * 60 * 1000;
169
+ /** The agent says what it is doing; the studio shows it, with how long, until it listens, reviews or 20 minutes pass. */
170
+ export function setWorking(dir, on) { return updatePiece(dir, (p) => { delete p.ask; p.working = { on: String(on ?? '').trim() || null, at: now() }; }); }
171
+ /** A short reply from the agent to the user, shown in the studio's thread. */
172
+ export function addNote(dir, text) { return updatePiece(dir, (p) => { (p.notes ??= []).push({ at: now(), text: String(text).trim() }); }); }
173
+
174
+ /**
175
+ * What the studio may say about the agent, only from facts: `ask` (a question is open), `listen` (a command of the
176
+ * agent is waiting on this piece right now), `work` (it said what it is doing, less than 20 minutes ago) or `off`.
177
+ */
178
+ export function agentState(dir, p = readPiece(dir)) {
179
+ const l = listening(dir), w = p?.working && Date.now() - Date.parse(p.working.at) < WORKING_FOR ? p.working : null;
180
+ return { state: p?.ask ? 'ask' : l ? 'listen' : w ? 'work' : 'off', listening: !!l, working: w };
181
+ }
182
+
123
183
  // an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
124
184
  const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin || e.refinal;
125
185
 
@@ -152,7 +212,15 @@ const beatFile = (dir) => path.join(dir, '.agent.json');
152
212
  export function beat(dir, info = {}) { try { fs.writeFileSync(beatFile(dir), JSON.stringify({ at: Date.now(), pid: process.pid, ...info })); } catch { /* a read-only project: the studio just won't know */ } }
153
213
  export function unbeat(dir) { try { if (JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')).pid === process.pid) fs.rmSync(beatFile(dir)); } catch { /* already gone */ } }
154
214
  /** The command waiting on this piece right now, if any. */
155
- export function listening(dir, within = 9000) { try { const a = JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')); return Date.now() - a.at < within ? a : null; } catch { return null; } }
215
+ export function listening(dir, within = 9000) {
216
+ try {
217
+ const a = JSON.parse(fs.readFileSync(beatFile(dir), 'utf8'));
218
+ if (Date.now() - a.at >= within) return null;
219
+ // a command that was killed leaves its last beat behind: it only counts while its process is alive
220
+ if (a.pid) { try { process.kill(a.pid, 0); } catch (e) { if (e.code === 'ESRCH') return null; } }
221
+ return a;
222
+ } catch { return null; }
223
+ }
156
224
 
157
225
  /** The agent asked the user something in the chat: the studio says so until the agent moves on. */
158
226
  export function setAsk(dir, text) { return updatePiece(dir, (p) => { p.ask = { text: String(text ?? '').trim(), at: now() }; }); }
@@ -196,6 +264,49 @@ export function setSceneText(html, key, value) {
196
264
  return head + next + tail;
197
265
  }
198
266
 
267
+ // ── choices: a board shown inside the studio instead of a link (ADR 0005) ─────────────────────────────────────
268
+ /** Puts a board into the piece, on the step and section it decides. Returns its id (`c1`, `c2`…). */
269
+ export function addChoice(dir, { board, where }) {
270
+ let id;
271
+ updatePiece(dir, (p) => {
272
+ p.choices ??= [];
273
+ id = `c${p.choices.length + 1}`;
274
+ p.choices.push({ id, board, where, status: 'waiting', at: now() });
275
+ });
276
+ return id;
277
+ }
278
+ /** The user's pick on a choice: records the reply line and an event the agent reads. */
279
+ export function settleChoice(p, id, line) {
280
+ const c = (p.choices ?? []).find((x) => x.id === id);
281
+ if (!c) throw new Error('no such choice');
282
+ if (c.status !== 'waiting') throw new Error('already chosen');
283
+ c.status = 'chosen'; c.line = line; c.chosenAt = now();
284
+ addEvent(p, { kind: 'choice', stage: c.where?.step ?? p.stage, text: line, title: c.board.title, choice: id });
285
+ return c;
286
+ }
287
+ /** Waits for the user's pick on a studio choice, beating as listening; returns the reply line, or null on timeout. */
288
+ export async function awaitChoiceIn(dir, id, { timeout = 10 * 60 * 1000, every = 1000 } = {}) {
289
+ const t0 = Date.now(); let last = 0;
290
+ const stop = () => { unbeat(dir); process.exit(130); };
291
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
292
+ try {
293
+ for (;;) {
294
+ if (Date.now() - last > 3000) { beat(dir, { waiting: 'board' }); last = Date.now(); }
295
+ const c = (readPiece(dir)?.choices ?? []).find((x) => x.id === id);
296
+ if (!c) return null;
297
+ if (c.status === 'chosen') {
298
+ // the agent has the pick from here: the event that carries it counts as read
299
+ updatePiece(dir, (p) => { const at = now(); for (const e of p.events) if (e.kind === 'choice' && e.choice === id && !e.readAt) { e.readAt = at; if (e.n === (p.seen ?? 0) + 1) p.seen = e.n; } });
300
+ return c.line;
301
+ }
302
+ if (Date.now() - t0 > timeout) return null;
303
+ await new Promise((res) => setTimeout(res, every));
304
+ }
305
+ } finally { unbeat(dir); process.off('SIGINT', stop); process.off('SIGTERM', stop); }
306
+ }
307
+ /** Takes a choice back out of the studio. */
308
+ export function forgetChoice(dir, id) { return updatePiece(dir, (p) => { p.choices = (p.choices ?? []).filter((x) => x.id !== id); }); }
309
+
199
310
  // ── boards: the decisions taken on paper-motion.app, listed in the studio so the user finds them there too ──────
200
311
  const BOARD_STAGE = () => 'video';
201
312
 
@@ -204,7 +315,7 @@ export function activePiece(cwd, name) {
204
315
  if (name) { const dir = pieceDir(name, cwd); return readPiece(dir) ? dir : null; }
205
316
  const root = path.join(cwd, 'pieces');
206
317
  if (!fs.existsSync(root)) return null;
207
- const live = fs.readdirSync(root).map((d) => path.join(root, d)).filter((d) => { const p = readPiece(d); return p && ['brief', 'inventory', 'video'].includes(p.stage); });
318
+ const live = fs.readdirSync(root).map((d) => path.join(root, d)).filter((d) => { const p = readPiece(d); return p && ['brief', 'inventory', 'look', 'video'].includes(p.stage); });
208
319
  return live.length === 1 ? live[0] : null;
209
320
  }
210
321
 
package/lib/registry.mjs CHANGED
@@ -122,7 +122,11 @@ export function llmsTxt(items, site, { full = false } = {}) {
122
122
  (an approval, a change to make, or an edit they already made by hand: the brief, words on screen, lengths, joins,
123
123
  order; build on those, never undo them). Quick renders for review: \`chain <piece> --drafts\` (each part on its
124
124
  own) and \`chain <piece> --draft\` (joined). Only when the user can't open a browser, run the same stages in the
125
- chat.
125
+ chat. Between your turns keep \`npx paper-motion piece listen <piece>\` running in the background: it returns as
126
+ soon as the user needs you (a message, an answer, an approval), with lines like review's, so they never have to
127
+ come back to the chat (start it again after; exit 3 means nothing in 9 minutes). Say what you are doing with
128
+ \`piece working <piece> <stage> --on "…"\`, ask with \`piece ask <piece> "…"\` (they answer in the studio), and
129
+ reply there with \`piece say <piece> "…"\`. The studio only shows what it can check about you.
126
130
  3. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
127
131
  line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
128
132
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
@@ -164,9 +168,11 @@ export function llmsTxt(items, site, { full = false } = {}) {
164
168
  builds the video itself from what they kept, and then they edit it there, live (words, how each part arrives,
165
169
  backgrounds, settings, lengths, transitions, order, adding and swapping parts, colours, type, pace); every edit
166
170
  reaches you as a line. Review \`video\` to wait for their approval; they can render the final from the studio. \`chain --check\` warns when the total drifts from the brief's
167
- length. Boards you share while a piece is open also appear in its studio (Look or Structure), so the user finds them
168
- there; if your shell caps how long a command may run, share with \`--no-wait\` and then
169
- \`npx paper-motion board --await <id> --wait 2\` until it prints the line (exit code 3 = not yet).
171
+ length. While a piece is open and its studio runs, \`board --url\` puts the options into the studio, on the step
172
+ they decide (How it looks for palettes, type, backgrounds and moves; Your video for transitions and structures),
173
+ instead of making a link; it waits there and prints the same reply line, and a palette, font pair or background
174
+ the user picks is applied already. \`--link\` makes a link anyway. If your shell caps how long a command may run,
175
+ use \`--no-wait\` and then \`npx paper-motion board --await <c1…> --wait 2\` (exit code 3 = not yet).
170
176
 
171
177
  ## Choosing well: the metadata
172
178