paper-motion 0.3.0 → 0.4.1

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 (61) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/bin/pamo.mjs +222 -21
  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 +95 -18
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +342 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +531 -0
  51. package/lib/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/render.mjs +13 -9
  54. package/lib/studio.mjs +347 -33
  55. package/package.json +1 -1
  56. package/runtime/index.js +10 -1
  57. package/runtime/player.js +18 -6
  58. package/runtime/swatch.html +21 -0
  59. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  60. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  61. package/studio/index.html +455 -327
@@ -0,0 +1,531 @@
1
+ // The inventory: what a piece can really show, written down before its structure.
2
+ //
3
+ // pieces/<piece>/inventory.json { "items": [{ "id", "kind", …fields, "says"?, "must"?, "source"? }] }
4
+ // pieces/<piece>/plan.json the structures proposed from it (pamo plan), each beat with its block and its fill
5
+ //
6
+ // Blocks and shots declare the kinds they show (`needs` in the registry). So a structure is proposed from the content
7
+ // the user has, not from a mood, and `chain init --plan` installs each part with the user's own figures, screens and
8
+ // words already in its CONFIG.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ /** Every kind of content an inventory can hold: its fields, and the part it usually plays in a piece. */
14
+ export const KINDS = {
15
+ // figures
16
+ figure: { needs: ['value', 'label'], opt: ['unit'], is: 'one number and what it counts', role: 'prove' },
17
+ change: { needs: ['from', 'to', 'label'], opt: ['fromLabel', 'toLabel'], is: 'a before and an after in one figure', role: 'prove' },
18
+ share: { needs: ['value', 'label'], is: 'a percentage (0–100) and what it is a share of', role: 'prove' },
19
+ series: { needs: ['title', 'points'], opt: ['tag'], is: 'values in order: points [[label, value], …]', role: 'prove' },
20
+ table: { needs: ['rows'], opt: ['main'], is: 'named values: rows [[name, value], …]; main is the row that matters', role: 'prove' },
21
+ // the product, seen
22
+ screen: { needs: ['image'], opt: ['url', 'title', 'caption', 'device'], files: ['image'], is: 'a screenshot of the product (device: desktop or phone)', role: 'show' },
23
+ action: { needs: ['text', 'button'], opt: ['answer', 'image'], files: ['image'], is: 'one thing the user does: a line, the control, what it answers', role: 'show' },
24
+ form: { needs: ['title', 'fields', 'button'], opt: ['done'], is: 'a form: fields [[label, value], …] and its button', role: 'show' },
25
+ search: { needs: ['query', 'items'], opt: ['placeholder'], is: 'a search: the query and items [[name, type], …]', role: 'show' },
26
+ board: { needs: ['columns', 'move'], is: 'a board: columns [[name, [cards]], …] and one move [from, card, to, at]', role: 'show' },
27
+ toggle: { needs: ['title', 'toggle'], opt: ['rows'], is: 'a setting that flips the whole interface', role: 'show' },
28
+ events: { needs: ['items'], is: 'things that happen one after another: items [[title, detail], …]', role: 'show' },
29
+ // how it works
30
+ command: { needs: ['command', 'output'], opt: ['cwd', 'working'], is: 'a real command and its output (lines)', role: 'how' },
31
+ code: { needs: ['file', 'code'], opt: ['highlight'], is: 'real code, and the line that matters', role: 'how' },
32
+ rewrite: { needs: ['file', 'before', 'remove', 'add'], is: 'code that gets shorter: the lines that go and what replaces them', role: 'how' },
33
+ // words
34
+ line: { needs: ['text'], opt: ['mark'], is: 'one sentence; mark is the words that carry it', role: 'say' },
35
+ variants: { needs: ['before', 'words', 'after'], is: 'a sentence with one word that keeps changing', role: 'say' },
36
+ words: { needs: ['items'], is: 'a few single words or very short phrases', role: 'say' },
37
+ voice: { needs: ['script'], opt: ['words', 'emphasis'], is: 'what is said aloud; words [{ w, t }] are its timings', role: 'say' },
38
+ // the brand and its sound
39
+ logo: { needs: ['svg'], opt: ['name'], is: "the brand's own SVG logo", role: 'close' },
40
+ music: { needs: [], opt: ['bpm', 'offset', 'beats', 'file'], files: ['file'], is: 'the track: its tempo and first beat, or its beat times', role: 'bed' },
41
+ // real material nothing in the registry shows yet: listed so the gap is said out loud, never silently dropped
42
+ photo: { needs: ['image'], opt: ['caption'], files: ['image'], is: 'a photograph', role: 'show' },
43
+ clip: { needs: ['file'], opt: ['caption'], files: ['file'], is: 'a video clip', role: 'show' },
44
+ };
45
+ const ROLE_NAMES = { say: 'Line', show: 'Product', how: 'How', prove: 'Proof', close: 'Close' };
46
+ /** The fields of each kind a person can edit as plain text (lists and files are the agent's to change). */
47
+ export const EDITABLE = {
48
+ figure: ['value', 'unit', 'label'], change: ['from', 'fromLabel', 'to', 'toLabel', 'label'], share: ['value', 'label'], series: ['title', 'tag'],
49
+ screen: ['url', 'title', 'caption', 'device'], action: ['text', 'button', 'answer'], form: ['title', 'button', 'done'], search: ['query', 'placeholder'],
50
+ toggle: ['title', 'toggle'], command: ['command', 'cwd', 'working'], code: ['file', 'highlight'], rewrite: ['file'], line: ['text', 'mark'],
51
+ variants: ['before', 'after'], voice: ['script'], logo: ['name'], music: ['bpm', 'offset'], photo: ['caption'], clip: ['caption'],
52
+ };
53
+ /**
54
+ * How the inventory reads to someone who does not code (the studio speaks this, never the names above): the parts
55
+ * of a video it is sorted into, and for each kind its everyday name and the fields worth showing a person, with
56
+ * their labels. What is not listed (a command's folder and output, a file name, a device) is the agent's to fill.
57
+ */
58
+ export const GROUPS = [['say', 'What we say'], ['show', 'What we show'], ['prove', 'What proves it'], ['how', 'How it works'], ['brand', 'Your brand']];
59
+ export const PLAIN = {
60
+ line: { name: 'A sentence', group: 'say', fields: { text: 'Sentence', mark: 'Words to highlight' } },
61
+ variants: { name: 'A sentence with a word that changes', group: 'say', fields: { before: 'Starts with', words: 'The words that change', after: 'Ends with' } },
62
+ words: { name: 'A few words', group: 'say', fields: { items: 'Words' } },
63
+ voice: { name: 'What is said aloud', group: 'say', fields: { script: 'Script', emphasis: 'Words to stress' } },
64
+ screen: { name: 'A screenshot', group: 'show', fields: { title: 'Name', caption: 'Caption', url: 'Address' } },
65
+ photo: { name: 'A photo', group: 'show', fields: { caption: 'Caption' } },
66
+ clip: { name: 'A video clip', group: 'show', fields: { caption: 'Caption' } },
67
+ action: { name: 'Something people do in it', group: 'show', fields: { text: 'What they do', button: 'The button', answer: 'What happens' } },
68
+ form: { name: 'A form', group: 'show', fields: { title: 'Title', fields: 'Fields', button: 'The button', done: 'When it is done' } },
69
+ search: { name: 'A search', group: 'show', fields: { query: 'What is typed', items: 'Results' } },
70
+ board: { name: 'A board', group: 'show', fields: {} },
71
+ toggle: { name: 'A setting', group: 'show', fields: { title: 'Title', toggle: 'The switch' } },
72
+ events: { name: 'Notifications', group: 'show', fields: { items: 'Notifications' } },
73
+ figure: { name: 'A number', group: 'prove', fields: { value: 'Number', unit: 'Unit', label: 'What it counts' } },
74
+ change: { name: 'A before and after', group: 'prove', fields: { from: 'Before', fromLabel: 'Before, in words', to: 'After', toLabel: 'After, in words', label: 'What it measures' } },
75
+ share: { name: 'A percentage', group: 'prove', fields: { value: 'Percent', label: 'Of what' } },
76
+ series: { name: 'Numbers over time', group: 'prove', fields: { title: 'Title', points: 'Points', tag: 'Note' } },
77
+ table: { name: 'A list of values', group: 'prove', fields: { rows: 'Rows' } },
78
+ command: { name: 'A command people run', group: 'how', fields: { command: 'Command' } },
79
+ code: { name: 'A piece of code', group: 'how', fields: {} },
80
+ rewrite: { name: 'Code that gets shorter', group: 'how', fields: {} },
81
+ logo: { name: 'Your logo', group: 'brand', fields: { name: 'Name beside it' } },
82
+ music: { name: 'Your music', group: 'brand', fields: {} },
83
+ };
84
+ /** The kinds a person can add by hand in the studio: the ones made of plain text and numbers. */
85
+ export const ADDABLE = ['line', 'figure', 'change', 'share'];
86
+ const NUMERIC = new Set(['share.value', 'music.bpm', 'music.offset', 'code.highlight', 'table.main']);
87
+ /** Fields edited as several lines: `lines` one entry each, `pairs` "name | value" each (`pairn`: the value a number), `block` text as it is. */
88
+ export const MULTI = {
89
+ words: { items: 'lines' }, variants: { words: 'lines' }, voice: { emphasis: 'lines' }, command: { output: 'lines' },
90
+ table: { rows: 'pairs' }, events: { items: 'pairs' }, search: { items: 'pairs' }, form: { fields: 'pairs' }, series: { points: 'pairn' },
91
+ code: { code: 'block' }, rewrite: { before: 'block' },
92
+ };
93
+ /** A list as the text a person edits, and back. */
94
+ export const listText = (v, type) => (type === 'block' ? String(v ?? '') : (Array.isArray(v) ? v : []).map((x) => (Array.isArray(x) ? x.join(' | ') : typeof x === 'object' && x ? String(x.text ?? '') : String(x))).join('\n'));
95
+ function listValue(textValue, type) {
96
+ if (type === 'block') return String(textValue ?? '').replace(/\s+$/, '');
97
+ const rows = String(textValue ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
98
+ if (type === 'lines') return rows;
99
+ return rows.map((l) => {
100
+ const [a, ...rest] = l.split(/\s*\|\s*|\t+/), b = rest.join(' | ').trim();
101
+ if (!a || !b) throw new Error(`"${l}": write each row as name | value`);
102
+ if (type === 'pairn') { const n = Number(b.replace(/,/g, '')); if (!Number.isFinite(n)) throw new Error(`"${l}": the value after the | is a number`); return [a.trim(), n]; }
103
+ return [a.trim(), b];
104
+ });
105
+ }
106
+ /** What a command printed, made fit to show: no colour codes, no blank edges, a few lines at most. */
107
+ export function tidyOutput(textValue, max = 7) {
108
+ const lines = String(textValue ?? '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '').split('\n').map((l) => l.replace(/\s+$/, ''));
109
+ while (lines.length && !lines[0].trim()) lines.shift();
110
+ while (lines.length && !lines.at(-1).trim()) lines.pop();
111
+ // the block draws its own checks: a ✓ the command printed would show twice
112
+ const kept = lines.filter((l, i) => l.trim() || (lines[i - 1] ?? '').trim()).map((l) => l.replace(/^\s*[✓✔√]\s*/, '')).map((l) => (l.length > 78 ? `${l.slice(0, 77)}…` : l));
113
+ return kept.length <= max ? kept : [...kept.slice(0, max - 2), '…', kept.at(-1)];
114
+ }
115
+
116
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
117
+ export const inventoryFile = (dir) => path.join(dir, 'inventory.json');
118
+ export const planFile = (dir) => path.join(dir, 'plan.json');
119
+
120
+ /**
121
+ * The inventory as it is on disk, every item with an id. An item's `status` says where it stands with the user:
122
+ * none (or "kept") is in, "proposed" was gathered and waits for them, "removed" is out for good (and is kept on
123
+ * file so nothing proposes it again).
124
+ */
125
+ export function loadRaw(dir) {
126
+ const file = inventoryFile(dir);
127
+ if (!fs.existsSync(file)) return [];
128
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
129
+ return (Array.isArray(raw) ? raw : raw.items ?? []).map((it, i) => ({ ...it, id: String(it.id ?? `${it.kind ?? 'item'}-${i + 1}`) }));
130
+ }
131
+ export function saveRaw(dir, items) {
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ // one item per line: a person reads this file, and a diff of it should show one change as one line
134
+ fs.writeFileSync(inventoryFile(dir), `{\n "items": [\n${items.map((it) => ` ${JSON.stringify(it)}`).join(',\n')}\n ]\n}\n`);
135
+ }
136
+ /** Read, change, write. `fn` gets the raw items and may return a new list. */
137
+ export function editInventory(dir, fn) { const items = loadRaw(dir), out = fn(items) ?? items; saveRaw(dir, out); return out; }
138
+
139
+ /**
140
+ * Reads and checks an inventory. `items` are the ones that are in (what a plan is made of), `waiting` the
141
+ * proposals the user has not decided on, `all` both plus the removed; `problems` (in what is in) stop a plan.
142
+ */
143
+ export function readInventory(dir, { cwd = process.cwd() } = {}) {
144
+ const file = inventoryFile(dir);
145
+ if (!fs.existsSync(file)) throw new Error(`no ${path.relative(cwd, file)} yet: gather it (\`pamo inventory <piece> --from <url|README.md|brand>\`) or write it (\`pamo inventory kinds\` lists what it can hold)`);
146
+ let raw;
147
+ try { raw = loadRaw(dir); } catch (e) { throw new Error(`${path.relative(cwd, file)}: ${e.message}`); }
148
+ return normalize(raw, { cwd });
149
+ }
150
+
151
+ export function normalize(list, { cwd = process.cwd() } = {}) {
152
+ const all = [], ids = new Set(), stray = [];
153
+ list.forEach((src, i) => {
154
+ const it = { ...src }, K = KINDS[it.kind], problems = [];
155
+ it.id = String(it.id ?? `${it.kind ?? 'item'}-${i + 1}`);
156
+ it.status = ['proposed', 'removed'].includes(it.status) ? it.status : 'kept';
157
+ it.problems = problems;
158
+ if (!K) { stray.push(`${it.id}: kind "${it.kind}" is not one of ${Object.keys(KINDS).join(', ')}`); return; }
159
+ if (ids.has(it.id)) problems.push(`${it.id}: used twice`);
160
+ ids.add(it.id);
161
+ // what it still lacks; a proposal may lack things (a command found in a README has no output until someone runs it)
162
+ it.missing = K.needs.filter((f) => it[f] === undefined || it[f] === '' || (Array.isArray(it[f]) && !it[f].length));
163
+ for (const f of it.missing) problems.push(`${it.id}: a ${it.kind} needs "${f}" (${K.is})`);
164
+ for (const f of K.files ?? []) {
165
+ if (!it[f]) continue;
166
+ const rel = String(it[f]).replace(/^\//, '');
167
+ if (!fs.existsSync(path.join(cwd, rel))) problems.push(`${it.id}: ${rel} is not in the project`);
168
+ it[f] = `/${rel.split(path.sep).join('/')}`; // scenes are served from the project root
169
+ }
170
+ if (it.kind === 'figure') {
171
+ // "2 min" → 2 and " min", so it can count up; a figure that is not a number ("A+") is shown as written
172
+ const m = typeof it.value === 'string' && it.unit === undefined ? it.value.replace(/,/g, '').match(/^(-?\d+(?:\.\d+)?)(.*)$/) : null;
173
+ if (m) { it.value = Number(m[1]); it.unit = m[2]; }
174
+ it.unit ??= '';
175
+ it.numeric = typeof it.value === 'number';
176
+ it.display = `${typeof it.value === 'number' ? it.value.toLocaleString('en-US') : it.value}${it.unit}`;
177
+ }
178
+ if (it.kind === 'share' && !(it.value >= 0 && it.value <= 100)) problems.push(`${it.id}: a share is a number from 0 to 100`);
179
+ if (it.kind === 'series' && !(Array.isArray(it.points) && it.points.length >= 3 && it.points.every((p) => Array.isArray(p) && typeof p[1] === 'number'))) problems.push(`${it.id}: points are [[label, number], …], three or more`);
180
+ if (it.kind === 'table') it.main ??= 0;
181
+ if (it.kind === 'line' && it.mark) {
182
+ const at = it.text.indexOf(it.mark);
183
+ if (at < 0) problems.push(`${it.id}: mark "${it.mark}" is not in the text`);
184
+ else { it.before = it.text.slice(0, at).trimEnd(); it.after = it.text.slice(at + it.mark.length).trimStart(); }
185
+ }
186
+ if (it.kind === 'command' && Array.isArray(it.output)) {
187
+ it.output = it.output.map((o) => (typeof o === 'string' ? (o ? { check: true, text: o } : { text: '' }) : o));
188
+ if (!it.output.some((o) => o.result)) { const last = [...it.output].reverse().find((o) => o.text); if (last) last.result = true; }
189
+ }
190
+ all.push(it);
191
+ });
192
+ const items = all.filter((i) => i.status === 'kept'), waiting = all.filter((i) => i.status === 'proposed');
193
+ return { items, waiting, all, problems: [...stray, ...items.flatMap((i) => i.problems)] };
194
+ }
195
+
196
+ /** One line that says what an item is, for a beat's "what" and for reports. */
197
+ export function describe(it) {
198
+ const cut = (s, n = 60) => { s = String(s ?? '').replace(/\s+/g, ' ').trim(); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
199
+ switch (it.kind) {
200
+ case 'figure': return `${it.display} ${cut(it.label)}`;
201
+ case 'change': return `${it.from} → ${it.to} ${cut(it.label)}`;
202
+ case 'share': return `${it.value}% ${cut(it.label)}`;
203
+ case 'series': return `${cut(it.title)} (${it.points?.length ?? 0} points)`;
204
+ case 'table': return `${it.rows?.[it.main ?? 0]?.join(' ') ?? ''} among ${it.rows?.length ?? 0} values`;
205
+ case 'screen': return `the product${it.url ? ` at ${it.url}` : it.title ? `: ${it.title}` : ''}${it.device === 'phone' ? ', on a phone' : ''}`;
206
+ case 'action': return `${cut(it.text)} [${it.button}]`;
207
+ case 'form': return `the "${it.title}" form`;
208
+ case 'search': return `searching "${it.query}"`;
209
+ case 'board': return `a card moves on the board`;
210
+ case 'toggle': return `"${it.toggle}" flips the interface`;
211
+ case 'events': return `${it.items?.length ?? 0} notifications, ending on "${cut(it.items?.at(-1)?.[0], 30)}"`;
212
+ case 'command': return `${cut(it.command, 50)}, and its output`;
213
+ case 'code': return `${it.file}, written line by line`;
214
+ case 'rewrite': return `${it.file} gets shorter`;
215
+ case 'line': return cut(it.text);
216
+ case 'variants': return `${it.before}[${(it.words ?? []).join(' / ')}]${it.after}`;
217
+ case 'words': return (it.items ?? []).join(' ');
218
+ case 'voice': return cut(it.script);
219
+ case 'logo': return `the logo${it.name ? ` and ${it.name}` : ''}`;
220
+ case 'music': return it.bpm ? `the track, ${it.bpm} BPM` : 'the track';
221
+ default: return cut(it.caption ?? it.image ?? it.file ?? it.kind);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * One edit to the inventory, by hand (the studio) or by the agent: keep, remove or restore an item, mark it a must,
227
+ * change a plain field, add an item, or settle every proposal at once. Returns what to tell the agent it was.
228
+ */
229
+ export function applyEdit(dir, a) {
230
+ const text = (v) => String(v ?? '').trim();
231
+ const plainly = (it) => { try { return describe(normalize([it]).all[0] ?? it); } catch { return it.id; } };
232
+ let said;
233
+ editInventory(dir, (items) => {
234
+ const find = () => { const it = items.find((i) => i.id === a.id); if (!it) throw new Error(`no item "${a.id}"`); return it; };
235
+ const set = (it, v) => { if (v === 'kept') delete it.status; else if (['proposed', 'removed'].includes(v)) it.status = v; else throw new Error('bad status'); };
236
+ switch (a.type) {
237
+ case 'inv-status': { const it = find(); set(it, a.value); said = { key: it.id, text: a.value, human: `${a.value === 'removed' ? 'Removed' : 'Kept'}: ${plainly(it)}` }; break; }
238
+ case 'inv-rest': { const n = items.filter((i) => i.status === 'proposed'); n.forEach((i) => set(i, a.value)); said = { key: 'every proposal left', text: `${a.value} (${n.map((i) => i.id).join(', ')})`, human: `${a.value === 'removed' ? 'Removed' : 'Kept'} everything still to decide (${n.length})` }; break; }
239
+ case 'inv-show': { // which block shows it: the user's pick, or none again (the plan picks)
240
+ const it = find(); if (a.value) it.show = String(a.value); else delete it.show;
241
+ if (it.tune) { const bg = it.tune.background; it.tune = bg ? { background: bg } : undefined; if (!it.tune) delete it.tune; } // the old block's settings do not carry over; the background does
242
+ said = { key: `${it.id}.show`, text: a.value || 'any', human: `${a.value ? `Shown as ${a.title ?? a.value}` : 'Your agent picks how it is shown'}: ${plainly(it)}` }; break;
243
+ }
244
+ case 'inv-tune': { // a setting of the block that shows it (the studio checks it is one of the block's)
245
+ const it = find(), k = String(a.key); it.tune ??= {};
246
+ if (a.value === '' || a.value === null || a.value === undefined) delete it.tune[k]; else it.tune[k] = a.value;
247
+ if (!Object.keys(it.tune).length) delete it.tune;
248
+ said = { key: `${it.id}.tune.${k}`, text: String(a.value ?? ''), human: `Tuned (${a.label ?? k}${a.value === '' || a.value == null ? ' back to default' : `: ${a.value}`}): ${plainly(it)}` }; break;
249
+ }
250
+ case 'inv-must': { const it = find(); if (a.value) it.must = true; else delete it.must; said = { key: `${it.id}.must`, text: a.value ? 'yes' : 'no', human: `${a.value ? 'Must appear' : 'No longer a must'}: ${plainly(it)}` }; break; }
251
+ case 'inv-field': {
252
+ const it = find(), k = String(a.key), multi = MULTI[it.kind]?.[k];
253
+ if (multi) { const v = listValue(a.value, multi); if (multi === 'block' ? !v : !v.length) delete it[k]; else it[k] = v; said = { key: `${it.id}.${k}`, text: multi === 'block' ? `${String(v).split('\n').length} lines` : v.map((x) => (Array.isArray(x) ? x.join(' | ') : x)).join(' / '), human: `Corrected: ${plainly(it)}` }; break; }
254
+ if (!(EDITABLE[it.kind] ?? []).includes(k)) throw new Error(`"${k}" is not a field to edit by hand`);
255
+ const v = text(a.value), n = Number(v.replace(/,/g, ''));
256
+ if (!v) delete it[k]; else it[k] = (NUMERIC.has(`${it.kind}.${k}`) || (it.kind === 'figure' && k === 'value')) && Number.isFinite(n) && /^-?[\d.,]+$/.test(v) ? n : v;
257
+ if (it.kind === 'line' && it.mark && !String(it.text ?? '').includes(it.mark)) delete it.mark; // the marked words must still be in the sentence
258
+ said = { key: `${it.id}.${k}`, text: v, human: `Corrected: ${plainly(it)}` }; break;
259
+ }
260
+ case 'inv-add': {
261
+ if (!ADDABLE.includes(a.kind)) throw new Error(`add a ${ADDABLE.join(', a ')}; anything else, ask your agent`);
262
+ const K = KINDS[a.kind], it = { id: '', kind: a.kind };
263
+ for (const f of EDITABLE[a.kind]) { const v = text(a.fields?.[f]), n = Number(v.replace(/,/g, '')); if (v) it[f] = (a.kind === 'share' && f === 'value') || (a.kind === 'figure' && f === 'value' && /^-?[\d.,]+$/.test(v)) ? n : v; }
264
+ for (const f of K.needs) if (it[f] === undefined) throw new Error(`a ${a.kind} needs "${f}"`);
265
+ const base = String(it.text ?? it.label ?? a.kind).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').trim().split(/\s+/).slice(0, 3).join('-') || a.kind;
266
+ let id = base, n = 2; while (items.some((i) => i.id === id)) id = `${base}-${n++}`;
267
+ it.id = id; it.source = 'you, in the studio';
268
+ items.push(it); said = { key: `added ${id}`, text: plainly(it), human: `Added: ${plainly(it)}` }; break;
269
+ }
270
+ default: throw new Error(`unknown inventory edit ${a.type}`);
271
+ }
272
+ });
273
+ return said;
274
+ }
275
+
276
+ // ── matching content to scenes ──────────────────────────────────────────────────────────────────────────────────
277
+
278
+ /** Blocks and shots that show content, from a registry index. */
279
+ export const scenesOf = (index) => (index.items ?? index).filter((i) => (i.type === 'block' || i.type === 'shot') && Array.isArray(i.needs));
280
+ const lead = (b) => b.needs[0];
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
+ // hit to read; a scene that ends on something else keeps most of its length
283
+ const LAND_HOLD = 0.6;
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
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);
293
+ // an item that says otherwise ("device: phone" for a block that prefers desktop) goes to this block last
294
+ const offKey = (need, it) => Object.entries(need.prefer ?? {}).some(([k, v]) => it[k] !== undefined && it[k] !== v);
295
+
296
+ /** The scenes that can show each item, best first. */
297
+ export function candidates(it, scenes) {
298
+ return scenes.filter((b) => b.needs.length && lead(b).kind !== 'name' && fits(lead(b), it))
299
+ .sort((a, b) => offKey(lead(a), it) - offKey(lead(b), it) || (lead(b).count ?? 1) - (lead(a).count ?? 1));
300
+ }
301
+
302
+ /** What the inventory holds, what can show it, and what is missing for a piece to work. */
303
+ export function report(items, scenes) {
304
+ const rows = items.map((it) => ({ it, blocks: candidates(it, scenes).map((b) => b.id) }));
305
+ const notes = [], has = (role) => items.some((i) => KINDS[i.kind].role === role && candidates(i, scenes).length);
306
+ for (const { it, blocks } of rows) {
307
+ if (blocks.length || it.kind === 'music' || it.kind === 'logo') continue;
308
+ notes.push(`${it.id}: nothing in the registry shows a ${it.kind} yet${it.must ? ' (and it is a must: build that part by hand)' : ''}`);
309
+ }
310
+ const lone = (b) => { const n = lead(b), pool = items.filter((i) => fits(n, i)); return (n.count ?? 1) > 1 && pool.length > 0 && pool.length < n.count ? `${b.id} takes ${n.count} ${n.kind}s and there ${pool.length === 1 ? 'is 1' : `are ${pool.length}`}` : null; };
311
+ for (const b of scenes) { const l = b.needs.length ? lone(b) : null; if (l) notes.push(l); }
312
+ if (!has('show')) notes.push('nothing shows the product: add a screen (a screenshot) or an action, or the piece only talks about it');
313
+ if (!has('prove')) notes.push('nothing proves it: add a figure, a change or a share that is true');
314
+ if (!has('say')) notes.push('no line: the piece opens without saying what this is');
315
+ return { rows, notes };
316
+ }
317
+
318
+ // ── proposing structures ────────────────────────────────────────────────────────────────────────────────────────
319
+
320
+ // Three ways to argue the same content. A slot is a role; `kind` narrows it, `or` is the role to fall back on.
321
+ const ARGUMENTS = [
322
+ { title: 'Show first', why: 'opens on the product itself, then proves it', slots: [{ role: 'say' }, { role: 'show' }, { role: 'show' }, { role: 'prove' }, { role: 'how' }, { role: 'prove' }] },
323
+ { title: 'Problem first', why: 'starts from what changes, then shows how', slots: [{ role: 'prove', kind: 'change', or: 'say' }, { role: 'show' }, { role: 'how' }, { role: 'prove' }, { role: 'show' }, { role: 'say' }] },
324
+ { title: 'Proof first', why: 'leads with the strongest figure, then earns it', slots: [{ role: 'prove' }, { role: 'say' }, { role: 'show' }, { role: 'how' }, { role: 'show' }, { role: 'prove' }] },
325
+ ];
326
+
327
+ /**
328
+ * Structures for a piece, from its inventory: each a chain of blocks and shots that shows the content in a
329
+ * different order. No item is shown twice in one structure, no block plays twice, and every must is in.
330
+ * @returns [{ letter, title, why, seconds, beats: [{ name, id, from, to, what, items, fill }], left: [ids] }]
331
+ */
332
+ export function propose(items, scenes, { seconds, brief = {} } = {}) {
333
+ const order = new Map(items.map((it, i) => [it.id, i]));
334
+ // a must first, then what the user took the time to shape (chose how it is shown, tuned it), then the order found
335
+ const cared = (i) => (i.show || (i.tune && Object.keys(i.tune).length) ? 1 : 0);
336
+ const rank = (a, b) => (b.must ? 1 : 0) - (a.must ? 1 : 0) || cared(b) - cared(a) || order.get(a.id) - order.get(b.id);
337
+ const closers = scenes.filter((b) => b.needs.length && lead(b).kind === 'name');
338
+ const logo = items.find((i) => i.kind === 'logo'), logoBlock = logo && scenes.find((b) => b.needs.length && fits(lead(b), logo));
339
+ const options = [], seen = new Set();
340
+
341
+ ARGUMENTS.forEach((arg, n) => {
342
+ const usedItems = new Set(), usedBlocks = new Map(), beats = [];
343
+ const free = (it) => !usedItems.has(it.id);
344
+ // a block plays once in a structure, unless it repeats (a setting such as its entrance makes each use different)
345
+ const uses = (b) => usedBlocks.get(b.id)?.length ?? 0;
346
+ const variants = (b) => (b.repeat ? (b.params ?? []).find((x) => x.name === b.repeat.param)?.options ?? [] : []);
347
+ const open = (b) => !uses(b) || (b.repeat && uses(b) < variants(b).length);
348
+ // the best free item for a role, and the block that shows it; each argument leans on a different block
349
+ const place = (role, kind) => {
350
+ const pool = items.filter((it) => free(it) && KINDS[it.kind].role === role && (!kind || it.kind === kind)).sort(rank);
351
+ for (const it of pool) {
352
+ // a block not used yet comes before one that would repeat: variety first
353
+ const blocks = candidates(it, scenes).filter((b) => open(b) && items.filter((x) => free(x) && fits(lead(b), x)).length >= (lead(b).count ?? 1)).sort((a, b) => uses(a) - uses(b));
354
+ if (!blocks.length) continue;
355
+ // among the blocks that fit it equally well (and take as many items), each argument picks a different one
356
+ const tier = blocks.filter((b) => uses(b) === uses(blocks[0]) && offKey(lead(b), it) === offKey(lead(blocks[0]), it) && (lead(b).count ?? 1) === (lead(blocks[0]).count ?? 1));
357
+ const b = blocks.find((x) => x.id === it.show) ?? tier[n % tier.length], count = lead(b).count ?? 1; // the user's pick wins
358
+ const group = count > 1 ? [it, ...items.filter((x) => x !== it && free(x) && fits(lead(b), x)).sort(rank).slice(0, count - 1)].sort((x, y) => order.get(x.id) - order.get(y.id)) : [it];
359
+ return take(b, group, role);
360
+ }
361
+ return false;
362
+ };
363
+ const take = (b, group, role) => {
364
+ group.forEach((g) => usedItems.add(g.id));
365
+ // a repeating block: the user's own setting for this item, else the next one this structure has not used yet
366
+ let variant = null;
367
+ if (b.repeat) {
368
+ const taken = usedBlocks.get(b.id) ?? [], mine = group[0].tune?.[b.repeat.param];
369
+ variant = mine ?? variants(b).slice(n * 3 % Math.max(1, variants(b).length)).concat(variants(b)).find((v) => !taken.includes(v)) ?? variants(b)[0];
370
+ }
371
+ usedBlocks.set(b.id, [...(usedBlocks.get(b.id) ?? []), variant]);
372
+ // what else the block takes: a required extra must be free, an optional one is used when it is
373
+ const extras = [];
374
+ for (const need of b.needs.slice(1)) {
375
+ if (need.kind === 'name') { extras.push([need, null]); continue; }
376
+ const x = items.filter((i) => free(i) && fits(need, i)).sort(rank)[0];
377
+ if (x && (!need.optional || KINDS[x.kind].role === 'bed')) { usedItems.add(x.id); extras.push([need, x]); }
378
+ }
379
+ const fill = { ...evalFill(lead(b).fill, group, brief) };
380
+ for (const [need, x] of extras) Object.assign(fill, evalFill(need.fill, x ? [x] : [], brief));
381
+ // how the user tuned this block for this item (its own settings, its background) goes into the part as well
382
+ for (const g of group) if (g.show === b.id || !g.show) Object.assign(fill, g.tune ?? {});
383
+ if (variant !== null) fill[b.repeat.param] = variant;
384
+ beats.push({ role, id: b.id, ...(variant !== null ? { variant } : {}), duration: b.duration ?? 5, min: floorOf(b), what: group.map(describe).join(' · '), items: [...group, ...extras.map(([, x]) => x).filter(Boolean)].map((g) => g.id), must: group.some((g) => g.must), fill });
385
+ return true;
386
+ };
387
+ for (const s of arg.slots) place(s.role, s.kind) || (s.or && place(s.or)) || (s.kind && place(s.role));
388
+ // a must that no slot took still goes in, with whatever shows it
389
+ for (const it of items.filter((i) => i.must && free(i)).sort(rank)) place(KINDS[it.kind].role, it.kind);
390
+ // while the length asked leaves room, what the user kept and no slot took goes in too, spread out: as far as it
391
+ // can sit from the beats of its own kind, so five sentences never play one after another
392
+ if (seconds) {
393
+ const room = () => seconds - beats.reduce((t, b) => t + b.min, 0) - 3.9; // 3.9: about what the close needs
394
+ for (const it of items.filter((i) => free(i) && i !== logo && KINDS[i.kind].role !== 'bed').sort(rank)) {
395
+ const c = candidates(it, scenes).find(open); if (!c || room() < floorOf(c)) continue;
396
+ const before = beats.length; if (!place(KINDS[it.kind].role, it.kind)) continue;
397
+ const [added] = beats.splice(before, 1);
398
+ let best = beats.length, score = -1;
399
+ for (let at = 1; at <= beats.length; at++) {
400
+ const gap = Math.min(...beats.map((x, i) => (x.role === added.role ? (i < at ? at - i : i - at + 1) : Infinity)));
401
+ if (gap >= score) { score = gap; best = at; }
402
+ }
403
+ beats.splice(best, 0, added);
404
+ }
405
+ }
406
+ if (!beats.length) return;
407
+ // the close: the brand's logo when the inventory brings it, else its name, a different way in each structure
408
+ const closer = logoBlock ?? (closers.length ? closers[n % closers.length] : null);
409
+ if (closer) {
410
+ const fill = logoBlock ? evalFill(lead(closer).fill, [logo], brief) : Object.assign({}, ...closer.needs.filter((x) => x.kind === 'name').map((x) => evalFill(x.fill, [], brief)));
411
+ beats.push({ role: 'close', id: closer.id, duration: closer.duration ?? 5, min: floorOf(closer), what: brief.action ? `the name and the action: ${brief.action}` : 'the name and the action', items: logoBlock ? [logo.id] : [], must: true, fill });
412
+ }
413
+ const key = beats.map((b) => `${b.id}:${b.items.join('+')}`).join('|');
414
+ if (seen.has(key)) return;
415
+ seen.add(key);
416
+ options.push({ title: arg.title, why: arg.why, ...timed(beats, seconds), left: items.filter((i) => free(i) && i !== logo && KINDS[i.kind].role !== 'bed').map((i) => i.id) });
417
+ });
418
+ return options.map((o, i) => ({ letter: String.fromCharCode(65 + i), ...o }));
419
+ }
420
+
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.
424
+ function timed(beats, target) {
425
+ let list = beats.map((b) => ({ ...b })), notes = [];
426
+ const sum = (k) => list.reduce((s, b) => s + b[k], 0);
427
+ if (target) {
428
+ // too long even at every floor: the last beat that is not a must goes, unless keeping it lands closer to the length asked
429
+ while (sum('min') > target && list.some((b) => !b.must)) {
430
+ const i = list.findLastIndex((b) => !b.must), over = sum('min') - target, under = target - (sum('duration') - list[i].duration);
431
+ if (over <= under) break;
432
+ notes.push(`dropped "${list[i].what}" to fit ${target} s`); list.splice(i, 1);
433
+ }
434
+ // share out what is left over the floors; a scene never runs past its own length, so a short piece stays short
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); }); }
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`);
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`);
440
+ }
441
+ let t = 0; const count = {};
442
+ const out = list.map((b, i) => {
443
+ const base = i === 0 && b.role === 'say' ? 'Hook' : ROLE_NAMES[b.role], k = (count[base] = (count[base] ?? 0) + 1);
444
+ const from = +t.toFixed(1); t += b.duration;
445
+ return { name: k > 1 ? `${base} ${k}` : base, id: b.id, ...(b.variant ? { variant: b.variant } : {}), from, to: +t.toFixed(1), what: b.what, items: b.items, fill: b.fill };
446
+ });
447
+ return { seconds: +t.toFixed(1), beats: out, notes };
448
+ }
449
+
450
+ /** A structure as the board's `--option "@Title | seconds | Beat a-b [block] what; …"`. */
451
+ export function boardOption(o) {
452
+ const clean = (s) => String(s).replace(/[;|\[\]]/g, ',').replace(/[`$]/g, '').replace(/\s+/g, ' ').trim();
453
+ return `@${clean(o.title)} | ${o.seconds} | ${o.beats.map((b) => `${clean(b.name)} ${b.from}-${b.to} [${b.id}] ${clean(b.what)}${b.variant ? `, ${b.variant}` : ''}`).join('; ')}`;
454
+ }
455
+
456
+ // ── filling a scene's CONFIG ────────────────────────────────────────────────────────────────────────────────────
457
+
458
+ /** A `fill` template evaluated against the items a need took. Keys whose value is missing are left out. */
459
+ export function evalFill(tpl, group, brief = {}) {
460
+ const item = group[0] ?? {};
461
+ const get = (it, ref) => { let v = ref.startsWith('brief.') ? brief : it; for (const k of (ref.startsWith('brief.') ? ref.slice(6) : ref).split('.')) v = v?.[k]; return v; };
462
+ const ev = (t, it) => {
463
+ if (typeof t === 'string') return t.startsWith('$') ? get(it, t.slice(1)) : t;
464
+ if (Array.isArray(t)) return t.map((x) => ev(x, it) ?? '');
465
+ if (t && typeof t === 'object') {
466
+ if ('$each' in t) return group.map((g) => ev(t.$each, g));
467
+ if ('$col' in t) return (it[t.$col[0]] ?? []).map((r) => r[t.$col[1]]);
468
+ if ('$argmax' in t) { const rows = it[t.$argmax] ?? []; return rows.reduce((best, r, i) => (r[1] > rows[best][1] ? i : best), 0); }
469
+ const o = {}; for (const [k, v] of Object.entries(t)) { const x = ev(v, it); if (x !== undefined) o[k] = x; } return o;
470
+ }
471
+ return t;
472
+ };
473
+ return ev(tpl ?? {}, item);
474
+ }
475
+
476
+ // a JS literal a person can still edit: one line when short, one entry per line when long, backticks for many lines
477
+ function lit(v, pad = ' ') {
478
+ if (typeof v === 'string') return v.includes('\n') ? `\`${v.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\`` : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
479
+ if (v === null || typeof v !== 'object') return String(v);
480
+ const key = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`);
481
+ const flat = Array.isArray(v) ? `[${v.map((x) => lit(x, pad)).join(', ')}]` : `{ ${Object.entries(v).map(([k, x]) => `${key(k)}: ${lit(x, pad)}`).join(', ')} }`;
482
+ if (!Array.isArray(v) || flat.length <= 96) return flat;
483
+ return `[\n${v.map((x) => `${pad} ${lit(x, `${pad} `)},`).join('\n')}\n${pad}]`;
484
+ }
485
+
486
+ // the top-level entries of `const CONFIG = { … };`, found by reading the code (a value may hold lines that look like keys)
487
+ function configEntries(body) {
488
+ const starts = [];
489
+ let depth = 0, quote = null, lineStart = true;
490
+ for (let i = 0; i < body.length; i++) {
491
+ const c = body[i], two = body.slice(i, i + 2);
492
+ if (lineStart && !quote && depth === 0) { const m = body.slice(i).match(/^ {2}(\w+): /); if (m) starts.push({ at: i, key: m[1] }); }
493
+ lineStart = c === '\n';
494
+ if (quote) { if (c === '\\') i++; else if (c === quote) quote = null; continue; }
495
+ if (two === '//') { const nl = body.indexOf('\n', i); i = (nl < 0 ? body.length : nl) - 1; continue; }
496
+ if (c === "'" || c === '"' || c === '`') quote = c;
497
+ else if ('([{'.includes(c)) depth++;
498
+ else if (')]}'.includes(c)) depth--;
499
+ }
500
+ return starts.map((s, k) => ({ key: s.key, from: s.at, to: k + 1 < starts.length ? starts[k + 1].at : body.length }));
501
+ }
502
+
503
+ /** Writes values into a scene's CONFIG, key by key; everything else in the scene stays as it was. */
504
+ export function fillConfig(html, values) {
505
+ const open = 'const CONFIG = {\n', start = html.indexOf(open), end = html.indexOf('\n};', start);
506
+ if (start < 0 || end < 0) throw new Error('this scene has no CONFIG');
507
+ const body = html.slice(start + open.length, end + 1), entries = configEntries(body), done = new Set();
508
+ let out = body.slice(0, entries[0]?.from ?? 0);
509
+ for (const e of entries) {
510
+ if (e.key in values) { done.add(e.key); out += ` ${e.key}: ${lit(values[e.key])},\n`; }
511
+ else out += body.slice(e.from, e.to);
512
+ }
513
+ const fresh = Object.entries(values).filter(([k]) => !done.has(k)).map(([k, v]) => ` ${k}: ${lit(v)},\n`).join('');
514
+ return `${html.slice(0, start)}${open}${out}${fresh}${html.slice(end + 1)}`;
515
+ }
516
+
517
+ // ── a plan on disk ──────────────────────────────────────────────────────────────────────────────────────────────
518
+
519
+ export function writePlan(dir, options, { seconds } = {}) {
520
+ fs.mkdirSync(dir, { recursive: true });
521
+ fs.writeFileSync(planFile(dir), JSON.stringify({ seconds: seconds ?? null, options }, null, 2) + '\n');
522
+ }
523
+
524
+ /** One structure of a plan, by its letter or title, as parts for `initChain` and the fill of each. */
525
+ export function planParts(dir, pick) {
526
+ const plan = readJson(planFile(dir), null);
527
+ if (!plan) throw new Error('no plan.json yet: run `pamo plan <piece>` first');
528
+ const o = plan.options.find((x) => x.letter === String(pick).toUpperCase() || x.title === pick);
529
+ if (!o) throw new Error(`plan has no option "${pick}" (it has ${plan.options.map((x) => x.letter).join(', ')})`);
530
+ return { title: o.title, parts: o.beats.map((b) => ({ id: b.id, name: b.name, seconds: +(b.to - b.from).toFixed(1), what: b.what, ...(b.items.length ? { shows: b.items } : {}) })), fills: o.beats.map((b) => b.fill) };
531
+ }