paper-motion 0.3.0 → 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 (59) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/bin/pamo.mjs +221 -20
  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 +75 -7
  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/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/studio.mjs +347 -33
  54. package/package.json +1 -1
  55. package/runtime/player.js +6 -3
  56. package/runtime/swatch.html +21 -0
  57. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  58. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  59. package/studio/index.html +439 -327
package/lib/chain.mjs CHANGED
@@ -13,15 +13,28 @@
13
13
  // whip the frame slides out sideways with a motion smear and the next slides in (0.35 s, the default)
14
14
  // dissolve a cross-fade (0.5 s)
15
15
  // dip out to the brand's ground color and back in (0.5 s)
16
+ // or a transition from the registry the user chose: wipe, push, zoom-through, iris, crossfade, slices, flash,
17
+ // scan-reveal, mask-reveal, glitch, cells, flip-3d, whip-pan (see MOVE_JOINS)
16
18
  // Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
17
19
  // land (the beat a block declares with B.cue(s, 'land', t)) gets a warning with the length that keeps it.
20
+ import crypto from 'node:crypto';
18
21
  import fs from 'node:fs';
19
22
  import os from 'node:os';
20
23
  import path from 'node:path';
21
24
  import { spawn, spawnSync } from 'node:child_process';
22
25
 
23
26
  export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
24
- const OVERLAP = new Set(['whip', 'dissolve']); // joins that need the outgoing part to run on under the next
27
+ // the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
28
+ // So a chosen transition plays where one part becomes the next (it can also be built inside a part, by hand).
29
+ export const MOVE_JOINS = {
30
+ crossfade: ['fade', 0.5], wipe: ['wipeleft', 0.5], push: ['slideleft', 0.45], 'zoom-through': ['zoomin', 0.5],
31
+ iris: ['circleopen', 0.55], slices: ['hrslice', 0.5], flash: ['fadewhite', 0.4], 'scan-reveal': ['wipedown', 0.5],
32
+ 'mask-reveal': ['radial', 0.55], glitch: ['pixelize', 0.35], cells: ['dissolve', 0.5], 'flip-3d': ['squeezeh', 0.45],
33
+ 'whip-pan': ['slideleft', 0.35],
34
+ };
35
+ for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
36
+ JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
37
+ const OVERLAP = new Set(['whip', 'dissolve', ...Object.keys(MOVE_JOINS)]); // joins that need the outgoing part to run on under the next
25
38
  const LAND_HOLD = 0.6; // seconds after a land for the hit to read
26
39
 
27
40
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
@@ -54,8 +67,10 @@ export async function structureFromBoard(ref, option, { site }) {
54
67
  if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
55
68
  else {
56
69
  const id = String(ref).replace(/\/$/, '').split('/').pop();
57
- const res = await fetch(`${site}/api/boards/${id}`);
58
- if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${site} (expired, or a long link is needed)`);
70
+ // a full link says where its board lives; a bare id is looked up on the site (--site, or the default)
71
+ const from = /^https?:\/\//i.test(String(ref)) ? new URL(String(ref)).origin : site;
72
+ const res = await fetch(`${from}/api/boards/${id}`);
73
+ if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${from} (expired or never stored there: pass the board's full link, the long #… link, or --site <url>)`);
59
74
  board = (await res.json()).board;
60
75
  }
61
76
  const opts = board?.options ?? [];
@@ -156,13 +171,58 @@ export function plan(chain, infos) {
156
171
  return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
157
172
  }
158
173
 
174
+ // ── is a joined render still current, and is one being made right now ──────────────────────────────────────
175
+ /**
176
+ * A fingerprint of everything a joined render shows: the order, lengths and joins in chain.json, each part's
177
+ * files, the brand's tokens and backgrounds, the project's moves. A render made from another fingerprint is stale.
178
+ */
179
+ export function chainSig(cwd, dir) {
180
+ const h = crypto.createHash('sha1');
181
+ const chain = readJson(path.join(dir, 'chain.json'), { parts: [] });
182
+ h.update(JSON.stringify([chain.format ?? null, chain.join ?? null, (chain.parts ?? []).map((p) => [p.dir, p.id, p.seconds ?? null, p.from ?? null, p.join ?? null])]));
183
+ const add = (f) => {
184
+ try {
185
+ const st = fs.statSync(f); if (st.isDirectory()) return;
186
+ h.update(path.basename(f));
187
+ if (/\.(html|json|js|mjs|css|svg)$/.test(f)) h.update(fs.readFileSync(f)); else h.update(`${st.size}:${Math.round(st.mtimeMs)}`);
188
+ } catch { /* gone: nothing to add */ }
189
+ };
190
+ const all = (d, skip = () => false) => { try { for (const f of fs.readdirSync(d).sort()) if (!skip(f)) add(path.join(d, f)); } catch { /* no such folder */ } };
191
+ for (const p of chain.parts ?? []) if (p.dir) all(path.join(dir, p.dir), (f) => f.endsWith('.md'));
192
+ for (const f of ['brand/tokens.json', 'brand/shader-set/set.json', 'brand/sound.json']) add(path.join(cwd, f));
193
+ all(path.join(cwd, 'moves'));
194
+ return h.digest('hex').slice(0, 16);
195
+ }
196
+ const sigFile = (dir) => path.join(dir, 'renders', '.sig.json');
197
+ /** What each joined render in renders/ was made from: { "<file>": { sig, sfx, audio } }. */
198
+ export const readSigs = (dir) => readJson(sigFile(dir), {});
199
+
200
+ // the join in progress, for the studio's "updating" notice and so two joins of the same file never run at once
201
+ const jobFile = (dir) => path.join(dir, '.job.json');
202
+ /** The join running on this piece right now (its process still alive), or null. */
203
+ export function readJob(dir) {
204
+ const j = readJson(jobFile(dir), null);
205
+ if (!j) return null;
206
+ try { process.kill(j.pid, 0); return j; } catch { return null; }
207
+ }
208
+ const writeJob = (dir, j) => { try { fs.writeFileSync(jobFile(dir), JSON.stringify({ pid: process.pid, at: Date.now(), ...j })); } catch { /* read-only: no notice */ } };
209
+ const endJob = (dir) => { try { if (readJson(jobFile(dir), {}).pid === process.pid) fs.rmSync(jobFile(dir)); } catch { /* already gone */ } };
210
+
159
211
  /**
160
212
  * Renders every part in the brand's look and joins them into one file.
161
213
  * Must run from the project root (where brand/ is): scenes load the brand from /brand.
162
214
  */
163
215
  export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
164
216
  if (draft) { dpr = 0.5; motionBlur = 1; }
165
- return withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
217
+ // the piece's own draft and final are tracked (what they were made from, and that they are being made);
218
+ // a render to another file (--out) is the caller's business
219
+ const home = pieceDir(piece, cwd), kind = out ? null : draft ? 'assembly' : 'final', sig = kind ? chainSig(cwd, home) : null;
220
+ if (kind) {
221
+ const busy = readJob(home);
222
+ if (busy && busy.pid !== process.pid && busy.kind === kind) throw new Error(`this piece's ${kind === 'assembly' ? 'draft' : 'final'} is already being rendered (${busy.of ? `part ${busy.part} of ${busy.of}` : 'starting'}): wait for it, then look at piece status`);
223
+ writeJob(home, { kind });
224
+ }
225
+ try { return await withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
166
226
  const { renderVideo } = await import('./render.mjs');
167
227
  for (const w of warnings) onStep({ warning: w });
168
228
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
@@ -170,6 +230,7 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
170
230
  const files = [];
171
231
  for (const [i, p] of parts.entries()) {
172
232
  onStep({ part: i + 1, of: parts.length, dir: p.dir });
233
+ if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
173
234
  const f = path.join(tmp, `${pad(i)}.mov`);
174
235
  await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
175
236
  files.push(f);
@@ -180,10 +241,12 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
180
241
  out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
181
242
  fs.mkdirSync(path.dirname(out), { recursive: true });
182
243
  onStep({ joining: true });
244
+ if (kind) writeJob(home, { kind, part: parts.length, of: parts.length, joining: true });
183
245
  const j = await join({ files, parts, out, ground, fps, music: audio, lufs: sound.loudness ?? -16 });
246
+ if (kind) fs.writeFileSync(sigFile(home), JSON.stringify({ ...readSigs(home), [path.basename(out)]: { sig, sfx, ...(audio ? { audio } : {}) } }, null, 2) + '\n');
184
247
  return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
185
248
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
186
- });
249
+ }); } finally { if (kind) endJob(home); }
187
250
  }
188
251
 
189
252
  /**
@@ -235,7 +298,12 @@ async function withChain({ cwd, piece }, fn) {
235
298
  try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
236
299
  const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
237
300
  if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
238
- return await fn({ dir, chain, url, config: infos[0].config, ...plan(chain, infos) });
301
+ const planned = plan(chain, infos);
302
+ // the length the brief asked for ("unos 20 s", "~20s", "16:9 · 20 s"): say so when the chain drifts from it
303
+ const brief = readJson(path.join(dir, 'piece.json'), null)?.brief?.format ?? '';
304
+ const want = Number(String(brief).match(/(\d+(?:[.,]\d+)?)\s*(?:s\b|sec|seg)/i)?.[1]?.replace(',', '.'));
305
+ if (want > 0 && Math.abs(planned.total - want) > Math.max(1.5, want * 0.08)) planned.warnings.push(`the chain runs ${planned.total.toFixed(1)} s and the brief asks for about ${want} s: change some parts' seconds in chain.json, or the brief`);
306
+ return await fn({ dir, chain, url, config: infos[0].config, ...planned });
239
307
  } finally { await server.close(); }
240
308
  }
241
309
 
@@ -264,7 +332,7 @@ async function join({ files, parts, out, ground, fps, music, lufs }) {
264
332
  const j = parts[i].join, d = JOINS[j];
265
333
  if (OVERLAP.has(j)) {
266
334
  const off = Math.max(0, len - d);
267
- g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
335
+ g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : MOVE_JOINS[j]?.[0] ?? 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
268
336
  g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
269
337
  if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
270
338
  len = off + lens[i].duration;
package/lib/customize.mjs CHANGED
@@ -63,8 +63,15 @@ export function recolor(item, pal) {
63
63
  return atLuminance(near.h, y) + (m[2] ? m[2] : '');
64
64
  };
65
65
  const out = {};
66
+ // the color behind everything takes the brand's ground exactly (its alpha kept), so the frame edge matches the brand
67
+ const ground = typeof pal?.ground === 'string' ? pal.ground : pal?.ground?.hex;
68
+ const behind = (name, v) => {
69
+ if (!/back/i.test(name) || !HEX.test(ground ?? '')) return null;
70
+ const m = typeof v === 'string' && v.match(HEX);
71
+ return m ? ground.slice(0, 7) + (m[2] ?? '') : null;
72
+ };
66
73
  for (const p of paramsOf(item)) {
67
- if (p.type === 'color') out[p.name] = one(p.default);
74
+ if (p.type === 'color') out[p.name] = behind(p.name, p.default) ?? one(p.default);
68
75
  else if (p.type === 'colors' && Array.isArray(p.default)) out[p.name] = p.default.map(one);
69
76
  }
70
77
  return out;
@@ -124,7 +131,38 @@ function backgroundEntry(item, values) {
124
131
  function paletteTokens(item, values) {
125
132
  const e = item.install.entry ?? {};
126
133
  const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
127
- return { ...e, colors, ramp: values.ramp ?? e.ramp };
134
+ return { ...e, colors, ramp: tunedRamp(e, colors, values.ramp) };
135
+ }
136
+
137
+ /**
138
+ * The ramp after the colors were tuned: steps equal to a role's old color take its new one; the steps between keep
139
+ * their tone and take the hue of the nearest new color. A ramp given explicitly wins.
140
+ */
141
+ function tunedRamp(e, colors, given) {
142
+ const ramp = e.ramp;
143
+ if (!Array.isArray(ramp) || (Array.isArray(given) && !same(given, ramp))) return given ?? ramp;
144
+ const old = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [String(c.hex).toLowerCase(), role]));
145
+ const fresh = Object.values(colors).map((c) => c.hex).filter((h) => HEX.test(h ?? ''));
146
+ if (Object.entries(e.colors ?? {}).every(([role, c]) => colors[role].hex === c.hex)) return ramp;
147
+ // a ramp from one role to another (ground → accent, the usual): the steps between are mixed again from the new ends,
148
+ // at the same place along the way (by tone), so the ramp keeps its hue
149
+ const a0 = old[String(ramp[0]).toLowerCase()], a1 = old[String(ramp.at(-1)).toLowerCase()];
150
+ if (a0 && a1 && a0 !== a1) {
151
+ const [y0, y1] = [lum(ramp[0]), lum(ramp.at(-1))], [n0, n1] = [srgb(colors[a0].hex).map(lin), srgb(colors[a1].hex).map(lin)];
152
+ return ramp.map((h, i) => {
153
+ if (i === 0) return colors[a0].hex;
154
+ if (i === ramp.length - 1) return colors[a1].hex;
155
+ const t = Math.min(1, Math.max(0, (lum(h) - y0) / ((y1 - y0) || 1)));
156
+ return hex(n0.map((c, k) => c + t * (n1[k] - c)));
157
+ });
158
+ }
159
+ const ladder = fresh.map((h) => ({ h, y: lum(h) }));
160
+ return ramp.map((h) => {
161
+ const role = old[String(h).toLowerCase()];
162
+ if (role) return colors[role].hex;
163
+ const y = lum(h), near = ladder.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a));
164
+ return atLuminance(near.h, y);
165
+ });
128
166
  }
129
167
 
130
168
  function fontTokens(item, values) {
package/lib/edit.mjs ADDED
@@ -0,0 +1,326 @@
1
+ // Your video: the piece as the user edits it in the studio. Everything here needs no judgment, so the studio does it
2
+ // itself and the agent only reads what happened: the words and numbers of a part, how it arrives, its background and
3
+ // settings, its length, the transition after it, the order, adding, duplicating, swapping and removing parts, the
4
+ // brand's colours, type and pace, and building the first version from the inventory. Every change can be undone.
5
+ //
6
+ // The files stay the source of truth: chain.json (order, seconds, joins), each part's scene.html CONFIG (its content
7
+ // and settings), brand/tokens.json and brand/motion.json (the style).
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import vm from 'node:vm';
12
+ import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe } from './inventory.mjs';
13
+
14
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
15
+ const writeJson = (f, v) => fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n');
16
+ const human = (k) => k.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
17
+
18
+ // ── a part's CONFIG ────────────────────────────────────────────────────────────────────────────────────────────
19
+
20
+ /** The CONFIG object of a scene, read as data (it is a literal: no code of the scene runs). */
21
+ export function readConfig(html) {
22
+ const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
23
+ if (start < 0 || end < 0) return {};
24
+ try { return vm.runInNewContext(`(${html.slice(start + 15, end + 2)})`, Object.create(null), { timeout: 200 }); } catch { return {}; }
25
+ }
26
+ const partFile = (dir, part) => path.join(dir, part.dir, 'scene.html');
27
+
28
+ // What a person may edit in a part, by how it is edited: words (`text`, `lines`, `rows`, `numbers`, `image`), and the
29
+ // labels they read. The block's own content keys come from its `needs`; its other words from its main text params.
30
+ const LABEL = {
31
+ text: 'Sentence', mark: 'Words in your accent colour', caption: 'Caption', title: 'Title', line: 'Line under it', label: 'What it counts',
32
+ command: 'Command', output: 'What it prints', cwd: 'Folder', working: 'While it works', stats: 'Numbers', image: 'Picture', url: 'Address',
33
+ word: 'The word', name: 'Your name', domain: 'Where to go', corner: 'In the corner', a: 'First line', b: 'Then', bLine: 'Line under it',
34
+ before: 'Before', after: 'After', words: 'The words that change', items: 'Items', toasts: 'Notifications', bars: 'Bars', points: 'Values',
35
+ labels: 'Labels', tag: 'Note', value: 'Number', rows: 'Rows', fields: 'Fields', button: 'The button', done: 'When it is done', answer: 'What it shows',
36
+ query: 'What is typed', placeholder: 'Placeholder', script: 'Script', emphasis: 'Words to stress', shots: 'Words, one per beat', toggle: 'The switch',
37
+ file: 'File name', code: 'Code', highlight: 'Line to light up', add: 'New lines', remove: 'Lines that go', svg: 'Logo (SVG)', columns: 'Columns', move: 'The move',
38
+ };
39
+ const SKIP = new Set(['background', 'format', 'entrance', 'metal']);
40
+ function asField(key, value) {
41
+ if (Array.isArray(value)) {
42
+ if (value.every((x) => typeof x === 'string' || typeof x === 'number')) return { key, kind: 'lines', value: value.join('\n') };
43
+ if (value.every((x) => Array.isArray(x))) {
44
+ // a column empty in every row (a unit nobody uses) is left out of what the person edits, and put back on save
45
+ const shape = value[0]?.map((c) => (typeof c === 'number' ? 'n' : Array.isArray(c) ? 'list' : 's')) ?? [];
46
+ const hide = shape.map((_, i) => value.every((r) => r[i] === '' || r[i] == null));
47
+ return { key, kind: 'rows', value: value.map((r) => r.filter((_, i) => !hide[i]).map((c) => (Array.isArray(c) ? c.join(', ') : c)).join(' | ')).join('\n'), shape, hide, columns: shape.length - hide.filter(Boolean).length };
48
+ }
49
+ if (value.every((x) => x && typeof x === 'object' && 'text' in x)) return { key, kind: 'lines', value: value.map((x) => x.text).join('\n'), objects: true };
50
+ return null;
51
+ }
52
+ if (value && typeof value === 'object') return Object.entries(value).filter(([, v]) => typeof v !== 'object').map(([k, v]) => ({ key: `${key}.${k}`, kind: typeof v === 'number' ? 'number' : 'text', value: String(v ?? ''), label: `${LABEL[key] ?? human(key)}: ${(LABEL[k] ?? human(k)).toLowerCase()}` }));
53
+ if (typeof value === 'number') return { key, kind: 'number', value: String(value) };
54
+ if (typeof value === 'string') return { key, kind: key === 'image' || /\.(jpe?g|png|webp)$/i.test(value) ? 'image' : value.includes('\n') ? 'block' : 'text', value };
55
+ return null;
56
+ }
57
+ export function wordFields(block, cfg) {
58
+ // the content a block always shows (an optional need, such as a track's tempo, is a setting, not words)
59
+ const keys = new Set(block.needs?.filter((n) => !n.optional).flatMap((n) => Object.keys(n.fill ?? {})) ?? []);
60
+ for (const p of block.params ?? []) if (p.type === 'string' && p.group === 'main' && p.control === 'text') keys.add(p.name);
61
+ return [...keys].filter((k) => !SKIP.has(k) && k in cfg).flatMap((k) => [asField(k, cfg[k])].flat().filter(Boolean)).map((f) => ({ ...f, label: f.label ?? LABEL[f.key] ?? human(f.key) }));
62
+ }
63
+ /** A field's text back into its CONFIG value, shaped like the value it replaces. */
64
+ function fromField(key, text, cfg) {
65
+ const [k, sub] = key.split('.'), old = cfg[k];
66
+ if (sub) { const o = { ...(old ?? {}) }; o[sub] = typeof old?.[sub] === 'number' && Number.isFinite(Number(text)) ? Number(text) : text; return [k, o]; }
67
+ const f = asField(k, old);
68
+ const rows = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
69
+ if (!f || f.kind === 'text' || f.kind === 'block' || f.kind === 'image') return [k, String(text ?? '')];
70
+ if (f.kind === 'number') { const n = Number(text); if (!Number.isFinite(n)) throw new Error('a number, please'); return [k, n]; }
71
+ if (f.kind === 'lines' && f.objects) return [k, rows.map((t, i) => ({ check: true, text: t.replace(/^[✓✔√]\s*/, ''), ...(i === rows.length - 1 ? { result: true } : {}) }))];
72
+ if (f.kind === 'lines') return [k, rows.map((t) => (typeof old?.[0] === 'number' && Number.isFinite(Number(t)) ? Number(t) : t))];
73
+ return [k, rows.map((l) => {
74
+ const typed = l.split(/\s*\|\s*/); let j = 0;
75
+ return f.shape.map((t, i) => { if (f.hide[i]) return t === 'n' ? 0 : ''; const c = typed[j++]; return t === 'n' ? Number(String(c ?? '0').replace(/,/g, '')) || 0 : t === 'list' ? String(c ?? '').split(/\s*,\s*/).filter(Boolean) : c ?? ''; });
76
+ })];
77
+ }
78
+
79
+ // What of a block a person may tune: its numbers and its either/or choices. The setting a repeating block varies
80
+ // (a sentence's entrance) is its Motion, chosen by watching each; the rest are Tune.
81
+ const NOT_TUNED = new Set(['format', 'background', 'fps', 'codec', 'winner', 'mark', 'detail', 'highlight']);
82
+ export function tunables(block, cfg = {}) {
83
+ const content = new Set(block.needs?.filter((n) => !n.optional).flatMap((n) => Object.keys(n.fill ?? {})) ?? []);
84
+ return (block.params ?? []).filter((p) => !content.has(p.name) && !NOT_TUNED.has(p.name) && p.name !== block.repeat?.param && (p.type === 'enum' || p.type === 'number')).map((p) => {
85
+ const d = p.default, num = p.type === 'number', min = num ? p.min ?? +(d * 0.4).toFixed(2) : null, max = num ? p.max ?? +(d * 1.8).toFixed(2) : null;
86
+ return { name: p.name, label: (p.description ?? p.name).replace(/^./, (c) => c.toUpperCase()), kind: num ? 'number' : 'enum', options: p.options ?? null, min, max, step: num ? +Math.max((max - min) / 40, 0.01).toFixed(2) : null, default: d, value: cfg[p.name] ?? d };
87
+ });
88
+ }
89
+
90
+ // ── undo ───────────────────────────────────────────────────────────────────────────────────────────────────────
91
+ // Before a change, the files it will touch are kept; undo puts the last set back. Forty steps.
92
+ const histFile = (dir) => path.join(dir, '.history.json');
93
+ export function snapshot(cwd, dir, files, label) {
94
+ const h = readJson(histFile(dir), []);
95
+ h.push({ label, at: new Date().toISOString(), files: Object.fromEntries(files.map((f) => [path.relative(cwd, f), fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : null])) });
96
+ writeJson(histFile(dir), h.slice(-40));
97
+ }
98
+ export function undo(cwd, dir) {
99
+ const h = readJson(histFile(dir), []), last = h.pop();
100
+ if (!last) throw new Error('nothing to undo');
101
+ for (const [rel, content] of Object.entries(last.files)) { const f = path.join(cwd, rel); if (content === null) fs.rmSync(f, { force: true }); else { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, content); } }
102
+ writeJson(histFile(dir), h);
103
+ return last.label;
104
+ }
105
+ export const canUndo = (dir) => readJson(histFile(dir), []).at(-1)?.label ?? null;
106
+
107
+ // ── reading the video ──────────────────────────────────────────────────────────────────────────────────────────
108
+
109
+ /** The parts as the editor shows them: each with its block, its words, motion, background, settings and join. */
110
+ export function videoView(cwd, dir, scenes) {
111
+ const chain = readJson(path.join(dir, 'chain.json'), null);
112
+ if (!chain) return null;
113
+ const byId = new Map(scenes.map((b) => [b.id, b]));
114
+ const parts = (chain.parts ?? []).filter((p) => p.dir && fs.existsSync(partFile(dir, p))).map((p, i, all) => {
115
+ const html = fs.readFileSync(partFile(dir, p), 'utf8'), cfg = readConfig(html), b = byId.get(p.id) ?? { id: p.id, params: [], needs: [] };
116
+ 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
+ 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,
119
+ join: i < all.length - 1 ? p.join ?? chain.join ?? 'whip' : null, shows: p.shows ?? [], what: p.what ?? '',
120
+ words: wordFields(b, cfg), motion, background: cfg.background ?? null, takesBackground: (b.params ?? []).some((x) => x.name === 'background'), tune: tunables(b, cfg),
121
+ mtime: Math.round(fs.statSync(partFile(dir, p)).mtimeMs),
122
+ };
123
+ });
124
+ return { title: chain.title, format: chain.format, parts, total: +parts.reduce((t, p) => t + Number(p.seconds || 0), 0).toFixed(1) };
125
+ }
126
+
127
+ /** A sentence made ready for any block that shows one: the words to mark (its last few, when none were chosen) and
128
+ * the text around them, worked out again from the sentence as it is now. */
129
+ function complete(i) {
130
+ if (i.kind !== 'line' || !i.text) return i;
131
+ let mark = i.mark && i.text.includes(i.mark) ? i.mark : null;
132
+ if (!mark) { const w = i.text.trim().split(/\s+/); mark = w.slice(-Math.max(1, Math.ceil(w.length / 3))).join(' '); }
133
+ const at = i.text.lastIndexOf(mark);
134
+ return { ...i, mark, before: i.text.slice(0, at).trimEnd(), after: i.text.slice(at + mark.length).trimStart() };
135
+ }
136
+
137
+ /** The reverse of a fill: which item fields a part's CONFIG holds, for the plain "$field" entries of its template. */
138
+ function unfill(tpl, cfg) {
139
+ const out = {};
140
+ for (const [k, v] of Object.entries(tpl ?? {})) if (typeof v === 'string' && v.startsWith('$') && !v.startsWith('$brief.') && cfg[k] !== undefined && typeof cfg[k] !== 'object') out[v.slice(1)] = cfg[k];
141
+ return out;
142
+ }
143
+
144
+ // ── changing it ────────────────────────────────────────────────────────────────────────────────────────────────
145
+
146
+ const pad = (n) => String(n).padStart(2, '0');
147
+ async function install(cwd, dir, blockId, format) {
148
+ const { add, getItem } = await import('./registry.mjs');
149
+ const it = await getItem(blockId);
150
+ const nums = fs.readdirSync(dir).map((d) => Number(d.match(/^(\d+)-/)?.[1] ?? 0)), sub = `${pad(Math.max(0, ...nums) + 1)}-${blockId}`;
151
+ const hasFormat = (it.params ?? []).some((x) => x.name === 'format');
152
+ await add([blockId], { cwd, force: true, name: path.join(path.relative(path.join(cwd, 'pieces'), dir), sub), set: hasFormat && format ? [`format=${format}`] : [] });
153
+ return { sub, item: it };
154
+ }
155
+
156
+ /**
157
+ * One edit to the video, from the studio. Returns { say } (what happened, in the user's words) and what to redraw.
158
+ * a: { type, part?, key?, value?, at?, block?, to? }
159
+ */
160
+ export async function editVideo(cwd, dir, a, scenes, brief = {}) {
161
+ const chainFile = path.join(dir, 'chain.json'), chain = readJson(chainFile, null);
162
+ if (!chain && !['build'].includes(a.type)) throw new Error('there is no video yet');
163
+ const find = () => { const p = chain.parts.find((x) => x.dir === a.part); if (!p) throw new Error('no such part'); return p; };
164
+ const byId = new Map(scenes.map((b) => [b.id, b]));
165
+ const keep = (files, label) => snapshot(cwd, dir, files, label);
166
+ const setCfg = (p, values) => { const f = partFile(dir, p); fs.writeFileSync(f, fillConfig(fs.readFileSync(f, 'utf8'), values)); };
167
+ const cfgOf = (p) => readConfig(fs.readFileSync(partFile(dir, p), 'utf8'));
168
+ const title = (p) => byId.get(p.id)?.title ?? p.id;
169
+ switch (a.type) {
170
+ case 'word': {
171
+ const p = find(); keep([partFile(dir, p)], `the words of ${title(p)}`);
172
+ const [k, v] = fromField(a.key, a.value, cfgOf(p)); setCfg(p, { [k]: v });
173
+ return { say: `Changed the words of ${title(p)}`, part: p.dir };
174
+ }
175
+ case 'motion': case 'tune': {
176
+ const p = find(), b = byId.get(p.id); keep([partFile(dir, p)], `${a.type === 'motion' ? 'how' : 'a setting of'} ${title(p)}`);
177
+ const t = a.type === 'motion' ? { name: b?.repeat?.param, kind: 'enum', options: (b?.params ?? []).find((x) => x.name === b?.repeat?.param)?.options } : tunables(b ?? {}, cfgOf(p)).find((x) => x.name === a.key);
178
+ if (!t?.name) throw new Error('that is not a setting of this part');
179
+ let v = a.value;
180
+ if (v === '' || v == null) v = (b.params ?? []).find((x) => x.name === t.name)?.default;
181
+ else if (t.kind === 'number') { v = Math.min(t.max, Math.max(t.min, Number(v))); if (!Number.isFinite(v)) throw new Error('a number, please'); }
182
+ else if (!t.options.includes(v)) throw new Error('not one of the choices');
183
+ setCfg(p, { [t.name]: v });
184
+ return { say: `${title(p)}: ${a.type === 'motion' ? 'arrives' : (t.label ?? t.name).toLowerCase()} ${v}`, part: p.dir };
185
+ }
186
+ case 'background': {
187
+ const takes = (p) => (byId.get(p.id)?.params ?? []).some((x) => x.name === 'background');
188
+ const targets = (a.all ? chain.parts.filter((x) => x.dir) : [find()]).filter(takes);
189
+ if (!targets.length) throw new Error('this part draws its own background');
190
+ keep(targets.map((p) => partFile(dir, p)), a.all ? 'the background of every part' : `the background of ${title(targets[0])}`);
191
+ for (const p of targets) setCfg(p, { background: a.value || null });
192
+ return { say: a.all ? `Background on every part: ${a.value || "the brand's first"}` : `${title(targets[0])}: background ${a.value || "the brand's first"}`, part: a.all ? null : targets[0].dir };
193
+ }
194
+ case 'seconds': {
195
+ 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 };
198
+ }
199
+ case 'join': {
200
+ const { JOINS } = await import('./chain.mjs'); if (!(a.value in JOINS)) throw new Error('bad transition');
201
+ keep([chainFile], 'a transition');
202
+ if (a.all) { for (const p of chain.parts) p.join = a.value; chain.join = a.value; } else find().join = a.value;
203
+ writeJson(chainFile, chain);
204
+ return { say: a.all ? `Every transition: ${a.value}` : `Transition after ${title(find())}: ${a.value}` };
205
+ }
206
+ case 'order': {
207
+ const by = new Map(chain.parts.map((x) => [x.dir, x]));
208
+ if (a.value.length !== chain.parts.length || a.value.some((d) => !by.has(d))) throw new Error('bad order');
209
+ keep([chainFile], 'the order'); chain.parts = a.value.map((d) => by.get(d)); writeJson(chainFile, chain);
210
+ return { say: 'New order' };
211
+ }
212
+ case 'remove': {
213
+ if (chain.parts.length < 2) throw new Error('a video needs one part at least');
214
+ const p = find(); keep([chainFile], `removing ${title(p)}`);
215
+ chain.parts = chain.parts.filter((x) => x !== p); writeJson(chainFile, chain);
216
+ return { say: `Removed ${title(p)}` };
217
+ }
218
+ case 'duplicate': {
219
+ const p = find(); keep([chainFile], `duplicating ${title(p)}`);
220
+ const nums = fs.readdirSync(dir).map((d) => Number(d.match(/^(\d+)-/)?.[1] ?? 0)), sub = `${pad(Math.max(0, ...nums) + 1)}-${p.id}`;
221
+ fs.mkdirSync(path.join(dir, sub), { recursive: true });
222
+ for (const f of fs.readdirSync(path.join(dir, p.dir))) if (fs.statSync(path.join(dir, p.dir, f)).isFile()) fs.copyFileSync(path.join(dir, p.dir, f), path.join(dir, sub, f));
223
+ chain.parts.splice(chain.parts.indexOf(p) + 1, 0, { ...p, dir: sub }); writeJson(chainFile, chain);
224
+ return { say: `Duplicated ${title(p)}`, part: sub };
225
+ }
226
+ case 'add': case 'swap': {
227
+ // a new block, filled with something true: the content of the part it replaces, else a kept inventory item it
228
+ // can show that is not in the video yet, else the block's own sample words for the user to replace
229
+ const b = byId.get(a.block); if (!b) throw new Error('no such block');
230
+ const old = a.type === 'swap' ? find() : null;
231
+ keep([chainFile], a.type === 'swap' ? `showing ${title(old)} another way` : `adding ${b.title}`);
232
+ const { sub } = await install(cwd, dir, b.id, chain.format);
233
+ const items = normalize(loadRaw(dir), { cwd }).items, shown = new Set(chain.parts.flatMap((x) => x.shows ?? []));
234
+ const need = b.needs?.[0], fits = (i) => need && i.kind === need.kind && (need.has ?? []).every((f) => i[f] !== undefined && i[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => i[k] === v);
235
+ // what the old part says now (the user may have changed its words since): read back through its own fill
236
+ const back = old ? unfill(byId.get(old.id)?.needs?.[0]?.fill, cfgOf(old)) : {};
237
+ if (back.mark !== undefined && back.before !== undefined) back.text = [back.before, back.mark, back.after].filter(Boolean).join(' '); // a marked sentence, whole again
238
+ if (typeof back.text === 'string') back.text = back.text.replace(/\s+/g, ' ').trim();
239
+ const from = (old?.shows ?? []).map((id) => items.find((i) => i.id === id)).filter(Boolean).map((i, k) => (k === 0 ? complete({ ...i, ...back }) : i));
240
+ // showing the same thing another way keeps its content, even when the new way wants a little more of it
241
+ const same = from.length && from.every((i) => i.kind === need?.kind);
242
+ const pool = same ? from : [...items.filter((i) => fits(i) && !shown.has(i.id))];
243
+ const group = need ? pool.slice(0, need.count ?? 1) : [];
244
+ const part = { id: b.id, dir: sub, name: b.title, seconds: b.duration ?? 4, ...(group.length ? { shows: group.map((i) => i.id), what: group.map(describe).join(' · ') } : {}) };
245
+ if (group.length >= (need?.count ?? 1)) {
246
+ const fill = evalFill(need.fill, group, brief);
247
+ for (const n of b.needs.slice(1)) if (n.kind === 'name') Object.assign(fill, evalFill(n.fill, [], brief));
248
+ setCfg(part, fill);
249
+ } else if (old && byId.get(old.id)?.needs?.[0]?.kind === need?.kind) {
250
+ // the same kind of content: carry the words over field by field where the names match
251
+ const prev = cfgOf(old), next = cfgOf(part), carry = Object.fromEntries(Object.keys(next).filter((k) => k in prev && !['format'].includes(k)).map((k) => [k, prev[k]]));
252
+ setCfg(part, carry);
253
+ }
254
+ if (old) { const bg = cfgOf(old).background; if (bg) setCfg(part, { background: bg }); part.join = old.join; part.seconds = old.seconds; chain.parts.splice(chain.parts.indexOf(old), 1, part); }
255
+ else chain.parts.splice(Math.max(0, Math.min(chain.parts.length, Number(a.at ?? chain.parts.length))), 0, part);
256
+ writeJson(chainFile, chain);
257
+ return { say: old ? `${title(old)} is now ${b.title}` : `Added ${b.title}${group.length ? `, showing ${part.what}` : ': write its words'}`, part: sub };
258
+ }
259
+ case 'build': {
260
+ // the first version, from what the user kept: the planner's structure, installed with its content
261
+ const items = normalize(loadRaw(dir), { cwd }).items;
262
+ if (!items.length) throw new Error('keep something to show first');
263
+ const secs = Number(String(brief.format ?? '').match(/(\d+(?:\.\d+)?)\s*(?:s\b|sec|seconds?)/i)?.[1]) || 25;
264
+ const options = propose(items, scenes, { seconds: secs, brief });
265
+ if (!options.length) throw new Error('nothing kept can be shown by a ready-made part yet');
266
+ writePlan(dir, options, { seconds: secs });
267
+ const o = options.find((x) => x.letter === (a.value ?? 'A')) ?? options[0];
268
+ if (chain) keep([chainFile], 'building it again');
269
+ const f = String(brief.format ?? ''), format = /9\s*:\s*16/.test(f) ? '1080x1920' : /1\s*:\s*1/.test(f) ? '1080x1080' : chain?.format ?? '1920x1080';
270
+ const look = await ensureLook(cwd, brief.title ?? chain?.title);
271
+ const { initChain } = await import('./chain.mjs');
272
+ const parts = o.beats.map((bt) => ({ id: bt.id, name: bt.name, seconds: +(bt.to - bt.from).toFixed(1), what: bt.what, ...(bt.items.length ? { shows: bt.items } : {}) }));
273
+ const r = await initChain({ cwd, piece: path.relative(path.join(cwd, 'pieces'), dir), title: brief.title ?? chain?.title, parts, format, force: true });
274
+ r.chain.parts.forEach((p, i) => { if (p.dir && o.beats[i]?.fill && Object.keys(o.beats[i].fill).length) setCfg(p, o.beats[i].fill); });
275
+ return { say: `Built your video: ${o.title.toLowerCase()}, ${o.beats.length} parts${look ? ` (no look yet: ${look.join(', ')}; change it in Style)` : ''}`, options: options.map((x) => ({ letter: x.letter, title: x.title, why: x.why })) };
276
+ }
277
+ default: throw new Error(`unknown edit ${a.type}`);
278
+ }
279
+ }
280
+
281
+ // ── the style: the brand's colours, type and pace ──────────────────────────────────────────────────────────────
282
+
283
+ export function styleView(cwd) {
284
+ const t = readJson(path.join(cwd, 'brand/tokens.json'), {}), m = readJson(path.join(cwd, 'brand/motion.json'), {});
285
+ const c = (k) => t.colors?.[k]?.hex ?? null;
286
+ const stamp = ['brand/tokens.json', 'brand/motion.json', 'brand/shader-set/set.json'].map((f) => { try { return Math.round(fs.statSync(path.join(cwd, f)).mtimeMs); } catch { return 0; } }).join('-');
287
+ // the brand's own pictures, for a part that shows one
288
+ const dir = path.join(cwd, 'brand/assets'), images = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f) && !/wordmark|logo/i.test(f)).map((f) => `/brand/assets/${f}`) : [];
289
+ return { colors: { ground: c('ground'), figure: c('figure'), accent: c('accent') }, fonts: { display: t.fonts?.display?.family ?? null, text: t.fonts?.text?.family ?? null }, pace: m.pace ?? 'medium', stamp, images };
290
+ }
291
+ /**
292
+ * A project with no look yet (no colours in brand/tokens.json) renders grey on grey and calls itself "brand".
293
+ * Before a video is built, give it a quiet default named after the piece, which the user changes in Style:
294
+ * a palette, a font pair and one background. Returns what was installed, or null when it already had a look.
295
+ */
296
+ export async function ensureLook(cwd, name) {
297
+ const t = readJson(path.join(cwd, 'brand/tokens.json'), {});
298
+ if (Object.keys(t.colors ?? {}).length) return null;
299
+ const { add } = await import('./registry.mjs');
300
+ await add(['graphite', 'geist-serif'], { cwd, ...(name ? { brand: name } : {}) });
301
+ const set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {});
302
+ const installed = ['graphite', 'geist-serif'];
303
+ if (!(Array.isArray(set) ? set : set.entries ?? []).length) { await add(['mesh-graphite'], { cwd, palette: 'brand' }); installed.push('mesh-graphite'); }
304
+ return installed;
305
+ }
306
+
307
+ 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');
310
+ if (a.type === 'palette' || a.type === 'font') {
311
+ const { add } = await import('./registry.mjs');
312
+ await add([a.value], { cwd, force: true });
313
+ return { say: `${a.type === 'palette' ? 'Colours' : 'Type'}: ${a.value}` };
314
+ }
315
+ if (a.type === 'color') {
316
+ 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);
318
+ return { say: `${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[a.key]} colour ${a.value}` };
319
+ }
320
+ if (a.type === 'pace') {
321
+ if (!['calm', 'medium', 'fast'].includes(a.value)) throw new Error('bad pace');
322
+ const m = readJson(motion, {}); m.pace = a.value; writeJson(motion, m);
323
+ return { say: `Pace: ${a.value}` };
324
+ }
325
+ throw new Error(`unknown style edit ${a.type}`);
326
+ }