paper-motion 0.4.0 → 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 (56) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/bin/pamo.mjs +63 -6
  3. package/lib/chain.mjs +20 -11
  4. package/lib/edit.mjs +51 -7
  5. package/lib/inventory.mjs +14 -6
  6. package/lib/piece.mjs +121 -10
  7. package/lib/registry.mjs +10 -4
  8. package/lib/render.mjs +13 -9
  9. package/lib/studio.mjs +195 -7
  10. package/package.json +2 -1
  11. package/runtime/brand.js +14 -2
  12. package/runtime/index.js +10 -1
  13. package/runtime/player.js +17 -6
  14. package/studio/index.html +255 -60
  15. package/examples/breath/stills/contact-sheet.jpg +0 -0
  16. package/examples/breath/stills/t000.00.png +0 -0
  17. package/examples/breath/stills/t002.00.png +0 -0
  18. package/examples/breath/stills/t004.00.png +0 -0
  19. package/examples/breath/stills/t006.00.png +0 -0
  20. package/examples/breath/stills/t008.00.png +0 -0
  21. package/examples/breath/stills/t010.00.png +0 -0
  22. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  23. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  24. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  25. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  26. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  27. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  28. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  29. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  30. package/examples/halo-explainer/stills/t000.00.png +0 -0
  31. package/examples/halo-explainer/stills/t006.00.png +0 -0
  32. package/examples/halo-explainer/stills/t012.00.png +0 -0
  33. package/examples/halo-explainer/stills/t018.00.png +0 -0
  34. package/examples/halo-explainer/stills/t024.00.png +0 -0
  35. package/examples/halo-explainer/stills/t030.00.png +0 -0
  36. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  37. package/examples/install-loop/stills/t000.00.png +0 -0
  38. package/examples/install-loop/stills/t001.00.png +0 -0
  39. package/examples/install-loop/stills/t002.00.png +0 -0
  40. package/examples/install-loop/stills/t003.00.png +0 -0
  41. package/examples/install-loop/stills/t004.00.png +0 -0
  42. package/examples/install-loop/stills/t005.00.png +0 -0
  43. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  44. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  45. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  46. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  47. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  48. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  49. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  50. package/examples/signal/stills/contact-sheet.jpg +0 -0
  51. package/examples/signal/stills/t000.00.png +0 -0
  52. package/examples/signal/stills/t001.67.png +0 -0
  53. package/examples/signal/stills/t003.33.png +0 -0
  54. package/examples/signal/stills/t005.00.png +0 -0
  55. package/examples/signal/stills/t006.67.png +0 -0
  56. package/examples/signal/stills/t008.33.png +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,100 @@
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
+
83
+ ## 0.4.1 · 2026-11-12
84
+
85
+ - **The studio's live previews are much lighter.** A scene opened with `?embed` draws its shaders at the size it is
86
+ shown, not the full frame times the screen's density (a Retina screen drew 8 million pixels a frame for a box
87
+ 700 px wide), skips layers no one can see, and plays at 30 frames a second. In a test of a part with six shots,
88
+ shader draws went from 180 to about 10 a second. Renders are unchanged.
89
+ - **A part always plays its whole scene: its length is its speed.** Shorter plays it faster, longer slower, never
90
+ cut short and never looped; sounds move with the picture and keep their pitch. Each part may run from 1.6× faster
91
+ to 0.6× slower than its scene (`SPEED`, `lengthRange` in inventory.mjs); a length outside that range is held at
92
+ the limit with a warning. The studio's Length slider runs over that range, says how much faster or slower the
93
+ part plays, and its live preview plays at that speed. The planner proposes lengths inside the range.
94
+ - **Previews in the studio play once and hold their last frame** (a click plays one again); "Play the whole video"
95
+ moves on when each part ends, instead of after a timer that let a part loop.
96
+ - **Beat cuts puts one word on each beat, never repeating, and ends on its last word**: its length is the words
97
+ at the tempo. The chain records each scene's real length, so the studio knows it.
98
+
5
99
  ## 0.4.0 · 2026-11-12
6
100
 
7
101
  A piece is built from what the user has to show, not from a mood.
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');
@@ -550,7 +591,7 @@ async function main() {
550
591
  await ensureDeps();
551
592
  if (args.check) {
552
593
  const r = await chainLib.checkChain({ piece });
553
- for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.from}–${+(p.from + p.seconds).toFixed(2)} s of ${p.D}`);
594
+ for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.seconds} s (its ${p.natural} s scene${Math.abs(p.rate - 1) > 0.005 ? `, ×${p.rate.toFixed(2)}` : ''})`);
554
595
  for (const w of r.warnings) console.log(` ! ${w}`);
555
596
  console.log(`${r.total.toFixed(1)} s in all`);
556
597
  break;
@@ -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/chain.mjs CHANGED
@@ -22,6 +22,7 @@ import fs from 'node:fs';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { spawn, spawnSync } from 'node:child_process';
25
+ import { lengthRange } from './inventory.mjs';
25
26
 
26
27
  export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
27
28
  // the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
@@ -35,7 +36,6 @@ export const MOVE_JOINS = {
35
36
  for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
36
37
  JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
37
38
  const OVERLAP = new Set(['whip', 'dissolve', ...Object.keys(MOVE_JOINS)]); // joins that need the outgoing part to run on under the next
38
- const LAND_HOLD = 0.6; // seconds after a land for the hit to read
39
39
 
40
40
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
41
41
  const pad = (i) => String(i + 1).padStart(2, '0');
@@ -156,17 +156,20 @@ export function plan(chain, infos) {
156
156
  const info = infos[i], D = info.config.duration, from = p.from ?? 0;
157
157
  const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
158
158
  if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
159
- let seconds = p.seconds ?? D - from;
160
- 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; }
161
- const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
162
- const land = lands.length ? Math.max(...lands) : null;
163
- 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`);
164
- return { ...p, from, seconds, join, D };
159
+ // a part plays its whole scene: another length plays it faster or slower (never cut, never looped), within limits
160
+ const natural = +(D - from).toFixed(3), range = lengthRange(natural);
161
+ let seconds = p.seconds ?? natural;
162
+ if (seconds < range.min - 1e-6 || seconds > range.max + 1e-6) {
163
+ const held = Math.min(range.max, Math.max(range.min, seconds));
164
+ warnings.push(`${p.dir}: ${seconds} s would play its ${natural} s scene too ${seconds < range.min ? 'fast' : 'slow'} (it takes ${range.min}–${range.max} s); it plays ${held} s`);
165
+ seconds = held;
166
+ }
167
+ return { ...p, from, seconds, join, D, natural, rate: natural / seconds };
165
168
  });
166
- // the outgoing part runs on under an overlapping join, as far as it can
169
+ // under an overlapping join the outgoing part holds its last frame while the next one comes in
167
170
  for (const [i, p] of parts.entries()) {
168
171
  const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
169
- p.to = Math.min(p.D, p.from + p.seconds + d);
172
+ p.out = +(p.seconds + d).toFixed(3);
170
173
  }
171
174
  return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
172
175
  }
@@ -232,7 +235,7 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
232
235
  onStep({ part: i + 1, of: parts.length, dir: p.dir });
233
236
  if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
234
237
  const f = path.join(tmp, `${pad(i)}.mov`);
235
- await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
238
+ await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.D, rate: p.rate, seconds: p.out, sfx, motionBlur, dpr });
236
239
  files.push(f);
237
240
  }
238
241
  const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
@@ -268,7 +271,7 @@ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep =
268
271
  mark(p.dir, { status: 'rendering' });
269
272
  const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
270
273
  try {
271
- await renderVideo({ url: url(p), out, from: p.from, to: p.from + p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
274
+ await renderVideo({ url: url(p), out, from: p.from, to: p.D, rate: p.rate, seconds: p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
272
275
  } catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
273
276
  const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
274
277
  mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
@@ -296,6 +299,12 @@ async function withChain({ cwd, piece }, fn) {
296
299
  try {
297
300
  const browser = await launch(), infos = [];
298
301
  try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
302
+ // what each scene really lasts (some depend on their content: one beat per word), for the studio's length slider
303
+ try {
304
+ fs.writeFileSync(path.join(dir, '.lengths.json'), JSON.stringify(Object.fromEntries(chain.parts.map((p, i) => [p.dir, {
305
+ duration: infos[i].config.duration, mtime: Math.round(fs.statSync(path.join(dir, p.dir, 'scene.html')).mtimeMs),
306
+ }])), null, 2) + '\n');
307
+ } catch { /* the slider falls back to the block's usual length */ }
299
308
  const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
300
309
  if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
301
310
  const planned = plan(chain, infos);
package/lib/edit.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import vm from 'node:vm';
12
- import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe } from './inventory.mjs';
12
+ import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe, lengthRange } from './inventory.mjs';
13
13
 
14
14
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
15
15
  const writeJson = (f, v) => fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n');
@@ -106,16 +106,29 @@ export const canUndo = (dir) => readJson(histFile(dir), []).at(-1)?.label ?? nul
106
106
 
107
107
  // ── reading the video ──────────────────────────────────────────────────────────────────────────────────────────
108
108
 
109
+ /**
110
+ * A part's length within what its scene allows: it always plays the whole scene, so a length is a speed. Returns the
111
+ * length (held inside the range), the scene's own length, the range and the speed (above 1 is faster).
112
+ */
113
+ export function timing(seconds, natural) {
114
+ const range = lengthRange(natural), s = Math.min(range.max, Math.max(range.min, seconds ?? natural));
115
+ return { seconds: s, duration: natural, range: { min: range.min, max: range.max }, rate: +(natural / s).toFixed(3) };
116
+ }
117
+
109
118
  /** The parts as the editor shows them: each with its block, its words, motion, background, settings and join. */
110
119
  export function videoView(cwd, dir, scenes) {
111
120
  const chain = readJson(path.join(dir, 'chain.json'), null);
112
121
  if (!chain) return null;
113
122
  const byId = new Map(scenes.map((b) => [b.id, b]));
123
+ // what each scene really lasts, read by the last join (a scene's length can depend on its content); stale once edited
124
+ const lengths = readJson(path.join(dir, '.lengths.json'), {});
125
+ const real = (p) => { const l = lengths[p.dir]; try { return l && l.mtime === Math.round(fs.statSync(partFile(dir, p)).mtimeMs) ? l.duration : null; } catch { return null; } };
114
126
  const parts = (chain.parts ?? []).filter((p) => p.dir && fs.existsSync(partFile(dir, p))).map((p, i, all) => {
115
127
  const html = fs.readFileSync(partFile(dir, p), 'utf8'), cfg = readConfig(html), b = byId.get(p.id) ?? { id: p.id, params: [], needs: [] };
128
+ const D = real(p);
116
129
  const motion = b.repeat ? { param: b.repeat.param, value: cfg[b.repeat.param] ?? null, options: (b.params ?? []).find((x) => x.name === b.repeat.param)?.options ?? [], previews: b.repeat.previews ?? {} } : null;
117
130
  return {
118
- dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', seconds: p.seconds ?? b.duration ?? 4, duration: b.duration ?? null,
131
+ dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', ...timing(p.seconds, D ?? b.duration ?? 4), exact: D !== null,
119
132
  join: i < all.length - 1 ? p.join ?? chain.join ?? 'whip' : null, shows: p.shows ?? [], what: p.what ?? '',
120
133
  words: wordFields(b, cfg), motion, background: cfg.background ?? null, takesBackground: (b.params ?? []).some((x) => x.name === 'background'), tune: tunables(b, cfg),
121
134
  mtime: Math.round(fs.statSync(partFile(dir, p)).mtimeMs),
@@ -193,8 +206,11 @@ export async function editVideo(cwd, dir, a, scenes, brief = {}) {
193
206
  }
194
207
  case 'seconds': {
195
208
  const p = find(), n = Number(a.value); if (!Number.isFinite(n)) throw new Error('give a length in seconds');
196
- keep([chainFile], `the length of ${title(p)}`); p.seconds = Math.max(1, Math.min(30, +n.toFixed(1))); writeJson(chainFile, chain);
197
- return { say: `${title(p)}: ${p.seconds} s`, part: p.dir };
209
+ const b = byId.get(p.id) ?? {}, natural = readJson(path.join(dir, '.lengths.json'), {})[p.dir]?.duration ?? b.duration ?? 4;
210
+ const t = timing(+n.toFixed(1), natural);
211
+ keep([chainFile], `the length of ${title(p)}`); p.seconds = t.seconds; writeJson(chainFile, chain);
212
+ const pace = t.rate > 1.005 ? `, ${Math.round((t.rate - 1) * 100)}% faster` : t.rate < 0.995 ? `, ${Math.round((1 - t.rate) * 100)}% slower` : '';
213
+ return { say: `${title(p)}: ${p.seconds} s${pace}`, part: p.dir };
198
214
  }
199
215
  case 'join': {
200
216
  const { JOINS } = await import('./chain.mjs'); if (!(a.value in JOINS)) throw new Error('bad transition');
@@ -304,19 +320,47 @@ export async function ensureLook(cwd, name) {
304
320
  return installed;
305
321
  }
306
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
+
307
337
  export async function editStyle(cwd, dir, a) {
308
- const tokens = path.join(cwd, 'brand/tokens.json'), motion = path.join(cwd, 'brand/motion.json');
309
- 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');
310
340
  if (a.type === 'palette' || a.type === 'font') {
311
341
  const { add } = await import('./registry.mjs');
312
342
  await add([a.value], { cwd, force: true });
343
+ if (a.type === 'palette') await recolorBackgrounds(cwd);
313
344
  return { say: `${a.type === 'palette' ? 'Colours' : 'Type'}: ${a.value}` };
314
345
  }
315
346
  if (a.type === 'color') {
316
347
  if (!/^#[0-9a-f]{6}$/i.test(a.value) || !['ground', 'figure', 'accent'].includes(a.key)) throw new Error('bad colour');
317
- 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);
318
350
  return { say: `${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[a.key]} colour ${a.value}` };
319
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
+ }
320
364
  if (a.type === 'pace') {
321
365
  if (!['calm', 'medium', 'fast'].includes(a.value)) throw new Error('bad pace');
322
366
  const m = readJson(motion, {}); m.pace = a.value; writeJson(motion, m);
package/lib/inventory.mjs CHANGED
@@ -281,7 +281,14 @@ const lead = (b) => b.needs[0];
281
281
  // the shortest a scene can run in a chain: its land (where its last beat, the hold, begins) plus the time for the
282
282
  // hit to read; a scene that ends on something else keeps most of its length
283
283
  const LAND_HOLD = 0.6;
284
- const floorOf = (b) => { const d = b.duration ?? 5, last = b.beats?.at(-1); return +(last && /^hold$/i.test(last.name) ? Math.min(d, last.from + LAND_HOLD + 0.1) : d * 0.8).toFixed(1); };
284
+ /**
285
+ * A part always plays its whole scene: a different length plays it faster or slower, never cut short and never
286
+ * looped. These are how far it may go, so motion still reads (too fast) and still moves (too slow).
287
+ */
288
+ export const SPEED = { fast: 1.6, slow: 0.6 };
289
+ /** The lengths a part may take, from its scene's own length: { natural, min, max } in seconds. */
290
+ export const lengthRange = (natural) => ({ natural, min: Math.ceil((natural / SPEED.fast) * 10) / 10, max: Math.floor((natural / SPEED.slow) * 10) / 10 });
291
+ const floorOf = (b) => lengthRange(b.duration ?? 5).min; // as fast as it may play
285
292
  const fits = (need, it) => it.kind === need.kind && (need.has ?? []).every((f) => it[f] !== undefined && it[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => it[k] === v);
286
293
  // an item that says otherwise ("device: phone" for a block that prefers desktop) goes to this block last
287
294
  const offKey = (need, it) => Object.entries(need.prefer ?? {}).some(([k, v]) => it[k] !== undefined && it[k] !== v);
@@ -411,9 +418,9 @@ export function propose(items, scenes, { seconds, brief = {} } = {}) {
411
418
  return options.map((o, i) => ({ letter: String.fromCharCode(65 + i), ...o }));
412
419
  }
413
420
 
414
- // Fits the beats to the length asked for. No beat runs shorter than its floor (its land must read), so a piece that
415
- // is too long loses its last beat that is not a must instead; what time is left over the floors is shared out. A
416
- // short piece stays short: a scene cannot run past its own length, and padding it would be a lie about the content.
421
+ // Fits the beats to the length asked for. Every beat plays its whole scene, faster or slower: none faster than its
422
+ // floor (SPEED.fast), so a piece that is too long loses its last beat that is not a must instead. A short piece plays
423
+ // a little slower (up to a quarter), never more: padding it further would be a lie about the content.
417
424
  function timed(beats, target) {
418
425
  let list = beats.map((b) => ({ ...b })), notes = [];
419
426
  const sum = (k) => list.reduce((s, b) => s + b[k], 0);
@@ -425,8 +432,9 @@ function timed(beats, target) {
425
432
  notes.push(`dropped "${list[i].what}" to fit ${target} s`); list.splice(i, 1);
426
433
  }
427
434
  // share out what is left over the floors; a scene never runs past its own length, so a short piece stays short
428
- const room = sum('duration') - sum('min'), k = target >= sum('duration') ? 1 : Math.max(0, (target - sum('min')) / (room || 1));
429
- list.forEach((b) => { b.duration = +(b.min + (b.duration - b.min) * k).toFixed(1); });
435
+ const total = sum('duration'), room = total - sum('min');
436
+ if (target >= total) { const g = Math.min(1.25, target / (total || 1)); list.forEach((b) => { b.duration = +(b.duration * g).toFixed(1); }); }
437
+ else { const k = Math.max(0, (target - sum('min')) / (room || 1)); list.forEach((b) => { b.duration = +(b.min + (b.duration - b.min) * k).toFixed(1); }); }
430
438
  if (sum('duration') > target * 1.05) notes.push(`runs ${sum('duration').toFixed(1)} s, over the ${target} s asked: every beat left is a must and none can run shorter`);
431
439
  if (sum('duration') < target * 0.9) notes.push(`runs ${sum('duration').toFixed(1)} s of the ${target} s asked: there is no more material to show`);
432
440
  }