paper-motion 0.2.7 → 0.4.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 (62) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/bin/pamo.mjs +332 -10
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +366 -0
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +326 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +523 -0
  51. package/lib/live-board.js +3 -1
  52. package/lib/piece.mjs +223 -0
  53. package/lib/registry.mjs +45 -11
  54. package/lib/studio.mjs +572 -0
  55. package/package.json +2 -1
  56. package/runtime/brand.js +1 -1
  57. package/runtime/index.js +1 -0
  58. package/runtime/player.js +6 -3
  59. package/runtime/swatch.html +21 -0
  60. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  61. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  62. package/studio/index.html +503 -0
package/lib/piece.mjs ADDED
@@ -0,0 +1,223 @@
1
+ // A piece's progress: pieces/<piece>/piece.json, shared by the agent (CLI) and the user (pamo studio).
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.
5
+ // stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
6
+ // brief what, who, format, tone, action: written by the agent, editable by the user
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)
10
+ // agent the app or page the agent runs in, so the studio can take the user back to it
11
+ //
12
+ // chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { pieceDir } from './chain.mjs';
16
+
17
+ export const STAGES = ['brief', 'inventory', 'video', 'final'];
18
+ // the stages a piece had before Your video took them over: a piece at one of them is at `video` now
19
+ const OLD_VIDEO = ['look', 'structure', 'parts', 'assembly'];
20
+ export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
21
+ export const BRIEF_KEYS = ['what', 'who', 'format', 'tone', 'action'];
22
+
23
+ const now = () => new Date().toISOString();
24
+ const file = (dir) => path.join(dir, 'piece.json');
25
+
26
+ export function readPiece(dir) {
27
+ try { return upgrade(JSON.parse(fs.readFileSync(file(dir), 'utf8'))); } catch { return null; }
28
+ }
29
+ // a piece started before a stage existed: that stage counts as done when the piece is already past it
30
+ function upgrade(p) {
31
+ if (!p.stages.video) {
32
+ const at = OLD_VIDEO.indexOf(p.stage), last = OLD_VIDEO.findLast((s) => p.stages[s]);
33
+ p.stages.video = at >= 0 ? { ...p.stages[p.stage] } : last && p.stages[last].status === 'approved' && STAGES.indexOf(p.stage) > 0 ? { status: 'approved' } : undefined;
34
+ if (at >= 0) p.stage = 'video';
35
+ for (const s of OLD_VIDEO) delete p.stages[s];
36
+ if (!p.stages.video) delete p.stages.video;
37
+ }
38
+ for (const s of STAGES) p.stages[s] ??= STAGES.indexOf(p.stage) > STAGES.indexOf(s) ? { status: 'approved', skipped: true } : { status: 'pending' };
39
+ return p;
40
+ }
41
+
42
+ /** Writes atomically (the studio and the CLI both write it). */
43
+ export function writePiece(dir, p) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const tmp = `${file(dir)}.${process.pid}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify(p, null, 2) + '\n');
47
+ fs.renameSync(tmp, file(dir));
48
+ return p;
49
+ }
50
+
51
+ /** Read, change, write: `fn` mutates the piece (re-read right before writing, so concurrent writers rarely clash). */
52
+ export function updatePiece(dir, fn) {
53
+ const p = readPiece(dir);
54
+ if (!p) throw new Error(`no piece.json in ${dir} (pamo piece init first)`);
55
+ fn(p);
56
+ p.updated = now();
57
+ return writePiece(dir, p);
58
+ }
59
+
60
+ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force = false }) {
61
+ const dir = pieceDir(piece, cwd), old = readPiece(dir);
62
+ if (old && !force) {
63
+ // an existing piece: fill in what was given, keep the rest
64
+ return updatePiece(dir, (p) => { if (title) p.title = title; for (const k of BRIEF_KEYS) if (brief[k]) p.brief[k] = brief[k]; });
65
+ }
66
+ const tokens = (() => { try { return JSON.parse(fs.readFileSync(path.join(cwd, 'brand/tokens.json'), 'utf8')); } catch { return {}; } })();
67
+ return writePiece(dir, {
68
+ v: 1, piece: path.basename(dir), title: title ?? path.basename(dir), brand: tokens.brand ?? null,
69
+ stage: 'brief',
70
+ stages: Object.fromEntries(STAGES.map((s, i) => [s, { status: i ? 'pending' : 'working' }])),
71
+ brief: Object.fromEntries(BRIEF_KEYS.map((k) => [k, brief[k] ?? ''])),
72
+ parts: {}, events: [], seen: 0, created: now(), updated: now(),
73
+ });
74
+ }
75
+
76
+ /** The line the agent reads for an event (same family as the board reply). */
77
+ export function eventLine(p, e) {
78
+ const who = e.part ? `part ${e.part}` : e.stage;
79
+ const what = e.kind === 'approve' ? 'approved'
80
+ : e.kind === 'changes' ? `changes: "${e.text}"`
81
+ : e.kind === 'order' ? `order ${e.text}`
82
+ : e.kind === 'reopen' ? 'reopened'
83
+ : e.kind === 'failed' ? `could not join the parts: "${e.text}"`
84
+ : e.say ? e.say : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
85
+ // the studio joins the draft again by itself after an edit by hand; the full-quality render stays the agent's
86
+ 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` : ''}`;
87
+ return `paper-motion: piece ${p.piece} · ${e.kind === 'order' ? '' : `${who} `}${what}${tail}`.replace(/ {2,}/g, ' ');
88
+ }
89
+
90
+ /** Appends a user event (from the studio). */
91
+ export function addEvent(p, e) {
92
+ const n = (p.events.at(-1)?.n ?? 0) + 1;
93
+ const ev = { n, at: now(), ...e };
94
+ p.events.push(ev);
95
+ return ev;
96
+ }
97
+
98
+ /** The agent hands a stage to the user: it becomes the current stage, in review. */
99
+ export function toReview(dir, stage, { skip = false } = {}) {
100
+ if (!STAGES.includes(stage)) throw new Error(`stage "${stage}": use ${STAGES.join(', ')}`);
101
+ return updatePiece(dir, (p) => {
102
+ // nothing moves on until the user approves: an earlier stage they have not approved stops the hand-over,
103
+ // unless the agent says outright that it is skipping it (`skip`), which marks it approved
104
+ const open = earlierOpen(p, stage);
105
+ 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
+ for (const s of open) p.stages[s] = { ...p.stages[s], status: 'approved' };
107
+ delete p.ask; // handing a stage over means the question in the chat is settled
108
+ p.stage = stage;
109
+ p.stages[stage] = { status: 'review', since: now() };
110
+ });
111
+ }
112
+
113
+ /** The stages before `stage` that the user has not approved. */
114
+ export const earlierOpen = (p, stage) => STAGES.slice(0, STAGES.indexOf(stage)).filter((s) => p.stages[s].status !== 'approved');
115
+
116
+ /** Events the agent hasn't read; marks them read when `mark`. */
117
+ export function unseen(dir, { mark = true } = {}) {
118
+ 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; });
120
+ return { piece: p, events: out };
121
+ }
122
+
123
+ // an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
124
+ const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin || e.refinal;
125
+
126
+ /**
127
+ * Waits until the user decides on `stage` in the studio (approves it, or asks for changes), then returns every
128
+ * event since the agent last looked, as lines. Edits the studio already applied (texts, seconds, order) come along.
129
+ */
130
+ export async function awaitDecision(dir, stage, { timeout = 30 * 60 * 1000, every = 1000, signal } = {}) {
131
+ const t0 = Date.now();
132
+ let last = 0;
133
+ try {
134
+ for (;;) {
135
+ if (Date.now() - last > 3000) { beat(dir, { waiting: stage }); last = Date.now(); } // the studio shows "your agent is listening"
136
+ const p = readPiece(dir);
137
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
138
+ if (fresh.some((e) => decisive(e, stage))) {
139
+ const r = unseen(dir);
140
+ return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) };
141
+ }
142
+ if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: fresh, lines: fresh.map((e) => eventLine(p, e)) };
143
+ await new Promise((res) => setTimeout(res, every));
144
+ }
145
+ } finally { unbeat(dir); }
146
+ }
147
+
148
+ // ── the agent's side of the conversation: is it listening, what did it ask, where does it live ────────────────
149
+ // While a command waits for the user (piece review, board), it says so every few seconds in a small file beside
150
+ // piece.json (piece.json itself stays quiet). No fresh beat means nobody reads what the user does in the studio.
151
+ const beatFile = (dir) => path.join(dir, '.agent.json');
152
+ 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
+ export function unbeat(dir) { try { if (JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')).pid === process.pid) fs.rmSync(beatFile(dir)); } catch { /* already gone */ } }
154
+ /** 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; } }
156
+
157
+ /** The agent asked the user something in the chat: the studio says so until the agent moves on. */
158
+ export function setAsk(dir, text) { return updatePiece(dir, (p) => { p.ask = { text: String(text ?? '').trim(), at: now() }; }); }
159
+ export function clearAsk(dir) { const p = readPiece(dir); return p?.ask ? updatePiece(dir, (q) => { delete q.ask; }) : p; }
160
+ /** Where the agent runs (see host.mjs), kept so the studio can take the user back there. */
161
+ export function setHost(dir, host) {
162
+ const p = readPiece(dir);
163
+ if (!p || !host || JSON.stringify(p.agent ?? null) === JSON.stringify(host)) return p;
164
+ return updatePiece(dir, (q) => { q.agent = host; });
165
+ }
166
+
167
+ // ── the words on screen: a scene's top-level string CONFIG entries ──────────────────────────
168
+ const CONFIG_STRING = /^ {2}(\w+): '((?:[^'\\]|\\.)*)',?\s*(\/\/.*)?$/;
169
+ const SKIP = new Set(['background', 'image', 'svg', 'font', 'fontFamily']);
170
+
171
+ /** The editable texts of a scene: top-level CONFIG strings (not ids or file paths). */
172
+ export function sceneTexts(html) {
173
+ const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
174
+ if (start < 0 || end < 0) return {};
175
+ const out = {};
176
+ for (const line of html.slice(start, end).split('\n')) {
177
+ const m = line.match(CONFIG_STRING);
178
+ if (m && !SKIP.has(m[1]) && !/\.(png|jpe?g|webp|svg|mp4|webm)$/.test(m[2])) out[m[1]] = m[2].replace(/\\(.)/g, '$1');
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** Rewrites one top-level CONFIG string in a scene. */
184
+ export function setSceneText(html, key, value) {
185
+ const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
186
+ const head = html.slice(0, start), body = html.slice(start, end), tail = html.slice(end);
187
+ const lit = `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
188
+ let hit = false;
189
+ const next = body.split('\n').map((line) => {
190
+ const m = line.match(CONFIG_STRING);
191
+ if (!m || m[1] !== key) return line;
192
+ hit = true;
193
+ return ` ${key}: ${lit},${m[3] ? ` ${m[3]}` : ''}`;
194
+ }).join('\n');
195
+ if (!hit) throw new Error(`CONFIG has no text "${key}"`);
196
+ return head + next + tail;
197
+ }
198
+
199
+ // ── boards: the decisions taken on paper-motion.app, listed in the studio so the user finds them there too ──────
200
+ const BOARD_STAGE = () => 'video';
201
+
202
+ /** The piece a board belongs to: the one named, else the only one in progress before Parts. */
203
+ export function activePiece(cwd, name) {
204
+ if (name) { const dir = pieceDir(name, cwd); return readPiece(dir) ? dir : null; }
205
+ const root = path.join(cwd, 'pieces');
206
+ 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); });
208
+ return live.length === 1 ? live[0] : null;
209
+ }
210
+
211
+ /** Records a board shared for this piece (shown in the studio's Look or Structure stage until chosen). */
212
+ export function attachBoard(dir, { url, id, title, kind }) {
213
+ return updatePiece(dir, (p) => {
214
+ p.boards ??= [];
215
+ if (id && p.boards.some((b) => b.id === id)) return;
216
+ p.boards.push({ url, id: id ?? null, title, kind, stage: BOARD_STAGE(p.stage), at: now(), status: 'waiting' });
217
+ });
218
+ }
219
+
220
+ /** Marks a board chosen with the reply line. */
221
+ export function markBoard(dir, id, line) {
222
+ return updatePiece(dir, (p) => { const b = (p.boards ?? []).find((x) => x.id === id); if (b) { b.status = 'chosen'; b.line = line; } });
223
+ }
package/lib/registry.mjs CHANGED
@@ -110,21 +110,31 @@ export function llmsTxt(items, site, { full = false } = {}) {
110
110
  ## The flow (for agents)
111
111
 
112
112
  1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
113
- ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`. Agents load
113
+ ffmpeg, Chrome). The CLI is \`npx paper-motion\`; where these docs write \`pamo\`, type \`npx paper-motion\` (the npm package named \`pamo\` is unrelated). \`npx paper-motion --version\` prints the version. Every command answers \`--help\`. Agents load
114
114
  skills and MCP servers when a session starts, so after installing the skills **read
115
115
  \`.claude/skills/make-video/SKILL.md\` now** (or \`.agents/skills/make-video/SKILL.md\`) and follow it in this
116
116
  session; tell the user that the MCP at ${site}/mcp, if they want it, is added before opening a session.
117
- 2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
117
+ 2. **Open the studio: it is where the user decides.** A video is made in stages the user watches and approves in
118
+ a local page in three steps (What it says: brief and inventory; Your video; Export). Start the piece right after setup:
119
+ \`npx paper-motion piece init <piece> --title "…"\` (it starts the project's studio in the background). Then,
120
+ at the end of each stage, \`npx paper-motion piece review <piece> <stage>\`: the first time it opens the studio in
121
+ the user's browser; it waits for their decision and prints what they did, one \`paper-motion: piece …\` line each
122
+ (an approval, a change to make, or an edit they already made by hand: the brief, words on screen, lengths, joins,
123
+ order; build on those, never undo them). Quick renders for review: \`chain <piece> --drafts\` (each part on its
124
+ own) and \`chain <piece> --draft\` (joined). Only when the user can't open a browser, run the same stages in the
125
+ chat.
126
+ 3. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
118
127
  line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
119
128
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
120
- Mirror it back before proposing anything. **You never choose a look for the user.** These decisions always go
129
+ Mirror it back, write it into the brief (\`piece init --what … --who … --format … --tone … --action …\`) and
130
+ \`piece review <piece> brief\`. **You never choose a look for the user.** These decisions always go
121
131
  through a board, in this order, one board each: palette · font pair · background(s) · moves by role (how things
122
132
  enter, how one thing becomes the next, how text arrives; let them pick several with \`choose\`) · recipe or shots
123
133
  (shots are 3–6 s camera gestures: a close-up, a pass over a component, a cursor that clicks; they chain before a
124
134
  recipe or stand alone) · blocks for the moments inside it (ready-made 4–8 s pieces: annotations on a screen, a
125
135
  form, a terminal, code typing, a chart, captions, a logo sting; each recomposes for 16:9, 9:16 and 1:1). Skip a
126
136
  board only when the brand folder already settles that decision, and say so.
127
- 3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
137
+ 4. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
128
138
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
129
139
  (format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
130
140
  with the board in the URL fragment otherwise. The user opens it, sees the options live in their look, may tune one,
@@ -134,16 +144,29 @@ export function llmsTxt(items, site, { full = false } = {}) {
134
144
  (\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
135
145
  reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
136
146
  they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
137
- 4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
147
+ 5. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
138
148
  \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
139
149
  fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
140
150
  whose prompt carries the whole \`pamo add\` command. \`add\` never overwrites a value already in the project: when
141
151
  the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
142
152
  \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
143
153
  from \`tokens.json\` \`brand\`: write those from the interview before rendering.
144
- 5. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
154
+ 6. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays
155
+ (a chosen transition may be the join between two parts of a chain: \`"join": "wipe"\` in chain.json takes its id);
145
156
  a recipe's own moves are placeholders to swap for theirs. Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
146
157
  \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
158
+ When the user chose a structure whose beats name blocks or shots (or several blocks in order), chain them instead:
159
+ \`npx paper-motion chain init <piece> --board <id> --option D\` installs each beat's block under
160
+ \`pieces/<piece>/<nn>-<id>/\` and writes \`chain.json\` (seconds per part; \`join\`: whip, cut, dissolve or dip);
161
+ write each part's \`CONFIG\` from its beat, then \`npx paper-motion chain <piece> --motion-blur 5\` renders every
162
+ part in the brand's look and joins them into one video. It warns when a part is cut before its land.
163
+ In the studio the stages are brief, inventory, video and final. When the user approves the inventory the studio
164
+ builds the video itself from what they kept, and then they edit it there, live (words, how each part arrives,
165
+ backgrounds, settings, lengths, transitions, order, adding and swapping parts, colours, type, pace); every edit
166
+ 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).
147
170
 
148
171
  ## Choosing well: the metadata
149
172
 
@@ -152,6 +175,16 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
152
175
  - \`when\`: 2–3 "use it when…" phrases.
153
176
  - \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
154
177
  - Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
178
+ - Blocks and shots: \`needs\`, the content each one shows by kind (a \`figure\`, a \`change\`, a \`screen\`, a \`command\`, a
179
+ \`line\`…). Choose them by what the user has to show, not by mood. Gather \`pieces/<piece>/inventory.json\` with
180
+ \`npx paper-motion inventory <piece> --from <their site> --from README.md --from brand\` (it reads the page and
181
+ screenshots it, a markdown file, a folder, or the brand's Facts and assets, and writes proposals, each with its
182
+ source; \`npx paper-motion inventory kinds\` lists the kinds and their fields). Complete what a proposal still
183
+ needs (a command's real output), add what gathering cannot see, and hand it over:
184
+ \`npx paper-motion piece review <piece> inventory\`. The user keeps or removes each proposal in the studio; only
185
+ what they kept is used. Then let \`npx paper-motion plan <piece> --seconds <n>\` propose
186
+ the structures: it prints the structure board to share, and \`chain init <piece> --plan <letter>\` installs the
187
+ chosen one with the content already in every part's CONFIG.
155
188
  - Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
156
189
  **Every background is a color family**: a board with a \`look.palette\` shows each background recolored into that
157
190
  palette (each of its colors keeps its tone and takes the nearest palette hue, so a dark one stays dark), and the
@@ -161,7 +194,8 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
161
194
  - Font pairs: \`voice\` (serif, sans, display, mono). Recipes, shots and blocks: \`duration\`, \`formats\` and \`beats\` (the structure as a
162
195
  timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
163
196
  name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
164
- format below), which you then build from scratch. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
197
+ format below), which you then build from scratch. Give each of its beats the \`id\` of the block or shot that shows
198
+ it: the board then plays those clips in order, so the user watches the structure instead of reading it. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
165
199
  order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
166
200
  reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
167
201
  the reference for its block.
@@ -203,9 +237,9 @@ A recipe board may hold one option that is a structure of your own for this brie
203
237
 
204
238
  \`\`\`json
205
239
  { "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
206
- { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
207
- { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
208
- { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action" } ] } }
240
+ { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field", "id": "word-swap" },
241
+ { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product", "id": "browser-frame" },
242
+ { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action", "id": "svg-logo" } ] } }
209
243
  \`\`\`
210
244
 
211
245
  Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
@@ -261,7 +295,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
261
295
  for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => typeof p !== 'string' || fs.existsSync(path.join(out, p)))); it.preview = Object.values(have).some((p) => typeof p === 'string') ? have : undefined; }
262
296
  const index = {
263
297
  name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
264
- items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
298
+ items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
265
299
  };
266
300
  fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
267
301
  fs.mkdirSync(path.join(out, 'files'), { recursive: true });