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/chain.mjs ADDED
@@ -0,0 +1,366 @@
1
+ // A chain: several blocks (or shots, recipes) rendered in the brand's look and joined into one piece.
2
+ //
3
+ // pieces/<piece>/chain.json what plays, how long, and how each part comes in
4
+ // pieces/<piece>/01-search-type/ each part is an ordinary installed scene: edit its CONFIG
5
+ //
6
+ // chain.json:
7
+ // { "title": "…", "format": "1920x1080", "join": "whip",
8
+ // "parts": [ { "id": "search-type", "dir": "01-search-type", "name": "Búsqueda", "seconds": 2.5 },
9
+ // { "id": "bar-grow", "dir": "02-bar-grow", "seconds": 3.2, "join": "dissolve", "from": 0 } ] }
10
+ //
11
+ // `join` on a part is how it comes in (the first part's is ignored); the chain's `join` is the default.
12
+ // cut a straight cut
13
+ // whip the frame slides out sideways with a motion smear and the next slides in (0.35 s, the default)
14
+ // dissolve a cross-fade (0.5 s)
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)
18
+ // Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
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';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { spawn, spawnSync } from 'node:child_process';
25
+
26
+ export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
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
38
+ const LAND_HOLD = 0.6; // seconds after a land for the hit to read
39
+
40
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
41
+ const pad = (i) => String(i + 1).padStart(2, '0');
42
+
43
+ /** Where a piece lives: a name (pieces/<name>) or a folder that holds chain.json. */
44
+ export function pieceDir(piece, cwd = process.cwd()) {
45
+ const asDir = path.resolve(cwd, piece);
46
+ if (fs.existsSync(path.join(asDir, 'chain.json')) || piece.includes(path.sep) || piece.startsWith('.')) return asDir;
47
+ return path.join(cwd, 'pieces', piece);
48
+ }
49
+
50
+ /** Parts from a structure (a board option): one part per beat, its block from the beat's id. */
51
+ export function partsFromStructure(st) {
52
+ return st.beats.map((b) => ({ id: b.id ?? null, name: b.name, seconds: +(b.to - b.from).toFixed(2), what: b.what }));
53
+ }
54
+
55
+ /** Parts from "search-type:2.5,browser-frame:3,bar-grow". */
56
+ export function partsFromList(list) {
57
+ return String(list).split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
58
+ const [id, sec] = x.split(':');
59
+ return { id, ...(sec ? { seconds: Number(sec) } : {}) };
60
+ });
61
+ }
62
+
63
+ /** A board's structure option, from a short id, a /b/<id> link or a long link (#fragment). */
64
+ export async function structureFromBoard(ref, option, { site }) {
65
+ const { decodeBoard } = await import('./live-board.js');
66
+ let board;
67
+ if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
68
+ else {
69
+ const id = String(ref).replace(/\/$/, '').split('/').pop();
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>)`);
74
+ board = (await res.json()).board;
75
+ }
76
+ const opts = board?.options ?? [];
77
+ // --option takes the letter from the reply line (D), or a structure's title; without it, the board's structure
78
+ const letter = option && /^[a-z]$/i.test(option) ? option.toUpperCase() : null;
79
+ const i = letter ? opts.findIndex((o, k) => (o.label ?? String.fromCharCode(65 + k)) === letter)
80
+ : option ? opts.findIndex((o) => o.structure?.title === option) : opts.findIndex((o) => o.structure);
81
+ const o = opts[i];
82
+ if (!o) throw new Error(`board: no option ${option ?? 'with a structure'} (it has ${opts.length})`);
83
+ if (o.structure) return { title: o.structure.title, parts: partsFromStructure(o.structure) };
84
+ return { title: board.title, parts: [{ id: o.id }] }; // a single block or recipe chosen: a chain of one
85
+ }
86
+
87
+ /**
88
+ * Installs each part as its own scene under pieces/<piece>/<nn>-<id>/ (in the requested format) and writes
89
+ * chain.json. Parts with no block (a beat the agent didn't name) are listed with id null for the agent to fill.
90
+ * Without parts, it syncs an existing chain.json: parts whose scene is missing are installed, the rest is kept.
91
+ */
92
+ export async function initChain({ cwd = process.cwd(), piece, title, parts, format, join, registry, force = false }) {
93
+ const { add, getItem, resolveRegistry } = await import('./registry.mjs');
94
+ const reg = resolveRegistry(registry);
95
+ const dir = pieceDir(piece, cwd), rel = path.relative(path.join(cwd, 'pieces'), dir), file = path.join(dir, 'chain.json');
96
+ const old = readJson(file, null);
97
+ if (old && parts && !force) throw new Error(`${path.relative(cwd, file)} exists: run init without --board/--parts to install parts you added, or --force to start over`);
98
+ if (!old && !parts) throw new Error(`no ${path.relative(cwd, file)} yet: give --board or --parts`);
99
+ const base = parts ? null : old;
100
+ format = format ?? base?.format ?? '1920x1080'; join = join ?? base?.join ?? 'whip';
101
+ if (!JOINS.hasOwnProperty(join)) throw new Error(`join "${join}": use ${Object.keys(JOINS).join(', ')}`);
102
+ const out = [], notes = [];
103
+ for (const [i, p] of (parts ?? old.parts).entries()) {
104
+ if (!p.id) { out.push({ ...p, id: null, dir: null }); notes.push(`part ${i + 1} "${p.name ?? '?'}" has no block yet: set its id in chain.json and run chain init ${piece} again`); continue; }
105
+ const sub = p.dir && p.dir.endsWith(`-${p.id}`) ? p.dir : `${pad(i)}-${p.id}`;
106
+ if (base && fs.existsSync(path.join(dir, sub, 'scene.html'))) { out.push({ ...p, dir: sub }); continue; } // installed already: your edits stay
107
+ const it = await getItem(p.id, reg);
108
+ if (it.install?.kind !== 'scene') throw new Error(`${p.id} is a ${it.type}, not a scene (chains take blocks, shots and recipes)`);
109
+ const hasFormat = (it.params ?? []).some((x) => x.name === 'format');
110
+ await add([p.id], { cwd, registry, force, name: path.join(rel, sub), set: hasFormat ? [`format=${format}`] : [] });
111
+ if (!hasFormat && format !== '1920x1080') notes.push(`${p.id} has no format param: it renders at its own size`);
112
+ const { id, name, seconds, what, dir: _, ...rest } = p; // rest: from, join, anything the agent added
113
+ out.push({ id, dir: sub, ...(name ? { name } : {}), ...(seconds ? { seconds } : {}), ...(what ? { what } : {}), ...rest });
114
+ }
115
+ const chain = { ...(base ?? {}), title: title ?? base?.title ?? piece, format, join, parts: out };
116
+ fs.mkdirSync(dir, { recursive: true });
117
+ fs.writeFileSync(file, JSON.stringify(chain, null, 2) + '\n');
118
+ const { readPiece, updatePiece } = await import('./piece.mjs');
119
+ if (readPiece(dir)) updatePiece(dir, (p) => {
120
+ const keep = new Set(out.map((x) => x.dir).filter(Boolean));
121
+ for (const d of Object.keys(p.parts)) if (!keep.has(d)) delete p.parts[d];
122
+ for (const d of keep) p.parts[d] ??= { status: 'queued' };
123
+ });
124
+ return { dir, chain, notes };
125
+ }
126
+
127
+ function run(bin, argv) {
128
+ return new Promise((res, rej) => {
129
+ const p = spawn(bin, argv, { stdio: ['ignore', 'ignore', 'pipe'] });
130
+ let err = '';
131
+ p.stderr.on('data', (d) => { err += d; });
132
+ p.on('error', rej);
133
+ p.on('close', (c) => (c === 0 ? res() : rej(new Error(`${bin} failed (${c}): ${err.trim().split('\n').slice(-6).join('\n')}`))));
134
+ });
135
+ }
136
+ const FF = () => process.env.PAPER_MOTION_FFMPEG || 'ffmpeg';
137
+ function probe(file) {
138
+ const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=codec_type', '-of', 'json', file], { encoding: 'utf8' });
139
+ const j = JSON.parse(r.stdout || '{}');
140
+ return { duration: Number(j.format?.duration ?? 0), audio: (j.streams ?? []).some((s) => s.codec_type === 'audio') };
141
+ }
142
+
143
+ /** What a scene declares: its config and the gestures it marks (land, transition, text). */
144
+ async function sceneInfo(browser, url) {
145
+ const { openScene } = await import('./browser.mjs');
146
+ const s = await openScene(browser, url);
147
+ const marks = await s.page.evaluate(() => window.__pm.marks ?? []);
148
+ await s.close();
149
+ return { config: s.config, marks };
150
+ }
151
+
152
+ /** Checks a chain and works out each part's window, without rendering. */
153
+ export function plan(chain, infos) {
154
+ const warnings = [];
155
+ const parts = chain.parts.map((p, i) => {
156
+ const info = infos[i], D = info.config.duration, from = p.from ?? 0;
157
+ const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
158
+ if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
159
+ let seconds = p.seconds ?? D - from;
160
+ if (from + seconds > D + 1e-6) { warnings.push(`${p.dir}: ${seconds} s from ${from} runs past its end (${D} s); it plays ${+(D - from).toFixed(2)} s`); seconds = D - from; }
161
+ const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
162
+ const land = lands.length ? Math.max(...lands) : null;
163
+ if (land !== null && from + seconds < land + LAND_HOLD) warnings.push(`${p.dir}: cut at ${seconds} s loses its land at ${+(land - from).toFixed(2)} s; give it at least ${+(land - from + LAND_HOLD).toFixed(1)} s`);
164
+ return { ...p, from, seconds, join, D };
165
+ });
166
+ // the outgoing part runs on under an overlapping join, as far as it can
167
+ for (const [i, p] of parts.entries()) {
168
+ const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
169
+ p.to = Math.min(p.D, p.from + p.seconds + d);
170
+ }
171
+ return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
172
+ }
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
+
211
+ /**
212
+ * Renders every part in the brand's look and joins them into one file.
213
+ * Must run from the project root (where brand/ is): scenes load the brand from /brand.
214
+ */
215
+ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
216
+ if (draft) { dpr = 0.5; motionBlur = 1; }
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 }) => {
226
+ const { renderVideo } = await import('./render.mjs');
227
+ for (const w of warnings) onStep({ warning: w });
228
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
229
+ try {
230
+ const files = [];
231
+ for (const [i, p] of parts.entries()) {
232
+ onStep({ part: i + 1, of: parts.length, dir: p.dir });
233
+ if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
234
+ const f = path.join(tmp, `${pad(i)}.mov`);
235
+ await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
236
+ files.push(f);
237
+ }
238
+ const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
239
+ const ground = (tokens.colors?.ground?.hex ?? '#000000').replace('#', '0x');
240
+ const { width: W, height: H, fps } = config;
241
+ out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
242
+ fs.mkdirSync(path.dirname(out), { recursive: true });
243
+ onStep({ joining: true });
244
+ if (kind) writeJob(home, { kind, part: parts.length, of: parts.length, joining: true });
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');
247
+ return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
248
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
249
+ }); } finally { if (kind) endJob(home); }
250
+ }
251
+
252
+ /**
253
+ * Renders parts on their own, quickly (half resolution, no motion blur), to pieces/<piece>/drafts/<dir>.mp4: what
254
+ * the user reviews part by part in the studio. Skips approved parts unless named in `only`. Tracks status in piece.json.
255
+ */
256
+ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep = () => {} }) {
257
+ const { readPiece, updatePiece } = await import('./piece.mjs');
258
+ return withChain({ cwd, piece }, async ({ dir, url, parts, warnings }) => {
259
+ const { renderVideo } = await import('./render.mjs');
260
+ for (const w of warnings) onStep({ warning: w });
261
+ const tracked = !!readPiece(dir), mark = (d, patch) => tracked && updatePiece(dir, (p) => { p.parts[d] = { ...(p.parts[d] ?? {}), ...patch }; });
262
+ const todo = parts.filter((p) => (only?.length ? only.some((o) => p.dir === o || p.id === o || p.dir.startsWith(`${o}-`)) : (readPiece(dir)?.parts?.[p.dir]?.status ?? 'queued') !== 'approved'));
263
+ for (const p of todo) mark(p.dir, { status: 'queued' });
264
+ fs.mkdirSync(path.join(dir, 'drafts'), { recursive: true });
265
+ const done = [];
266
+ for (const [i, p] of todo.entries()) {
267
+ onStep({ part: i + 1, of: todo.length, dir: p.dir });
268
+ mark(p.dir, { status: 'rendering' });
269
+ const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
270
+ try {
271
+ await renderVideo({ url: url(p), out, from: p.from, to: p.from + p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
272
+ } catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
273
+ const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
274
+ mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
275
+ done.push(out);
276
+ }
277
+ return { rendered: done, warnings };
278
+ });
279
+ }
280
+
281
+ /** Plans a chain without rendering: each part's window, its join, and the warnings. */
282
+ export const checkChain = ({ cwd = process.cwd(), piece }) => withChain({ cwd, piece }, async (r) => r);
283
+
284
+ // Serves the project, opens each part once to read its size, duration and marks, and plans the chain.
285
+ async function withChain({ cwd, piece }, fn) {
286
+ const dir = pieceDir(piece, cwd), chain = readJson(path.join(dir, 'chain.json'), null);
287
+ if (!chain) throw new Error(`no chain.json in ${path.relative(cwd, dir) || '.'} (pamo chain init first)`);
288
+ if (!chain.parts?.length) throw new Error('chain.json has no parts');
289
+ const missing = chain.parts.filter((p) => !p.id || !p.dir || !fs.existsSync(path.join(dir, p.dir, 'scene.html')));
290
+ if (missing.length) throw new Error(`parts without a scene: ${missing.map((p) => p.name ?? p.id ?? '?').join(', ')} (set their id in chain.json and run chain init again)`);
291
+ if (!fs.existsSync(path.join(cwd, 'brand'))) throw new Error('no brand/ here: run chain from the project root');
292
+ const { startServer } = await import('./server.mjs');
293
+ const { launch } = await import('./browser.mjs');
294
+ const server = await startServer({ root: cwd });
295
+ const url = (p) => `http://127.0.0.1:${server.port}/${path.relative(cwd, path.join(dir, p.dir, 'scene.html')).split(path.sep).map(encodeURIComponent).join('/')}?render`;
296
+ try {
297
+ const browser = await launch(), infos = [];
298
+ try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
299
+ const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
300
+ if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
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 });
307
+ } finally { await server.close(); }
308
+ }
309
+
310
+ /** The ffmpeg graph: each part's join applied in turn, sound carried along, loudness set once at the end. */
311
+ async function join({ files, parts, out, ground, fps, music, lufs }) {
312
+ const lens = files.map((f) => probe(f));
313
+ const inputs = files.flatMap((f) => ['-i', f]);
314
+ const g = [];
315
+ // every part gets a sound track (silent if it declares none) so the audio chain lines up
316
+ const A = lens.map((l, i) => {
317
+ if (l.audio) { g.push(`[${i}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=0:${l.duration.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]`); return `[a${i}]`; }
318
+ g.push(`anullsrc=r=48000:cl=stereo,atrim=0:${l.duration.toFixed(3)}[a${i}]`); return `[a${i}]`;
319
+ });
320
+ // a dip fades the parts on either side into the ground color
321
+ const V = parts.map((p, i) => {
322
+ const f = [`fps=${fps}`, 'format=yuv444p10le', 'settb=AVTB', 'setpts=PTS-STARTPTS'];
323
+ const half = JOINS.dip / 2;
324
+ if (parts[i + 1]?.join === 'dip') f.push(`fade=t=out:st=${(lens[i].duration - half).toFixed(3)}:d=${half}:color=${ground}`);
325
+ if (p.join === 'dip') f.push(`fade=t=in:st=0:d=${half}:color=${ground}`);
326
+ g.push(`[${i}:v]${f.join(',')}[v${i}]`);
327
+ return `[v${i}]`;
328
+ });
329
+ let v = V[0], a = A[0], len = lens[0].duration;
330
+ const smears = [];
331
+ for (let i = 1; i < parts.length; i++) {
332
+ const j = parts[i].join, d = JOINS[j];
333
+ if (OVERLAP.has(j)) {
334
+ const off = Math.max(0, len - d);
335
+ g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : MOVE_JOINS[j]?.[0] ?? 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
336
+ g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
337
+ if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
338
+ len = off + lens[i].duration;
339
+ } else {
340
+ g.push(`${v}${V[i]}concat=n=2:v=1:a=0[x${i}]`);
341
+ g.push(`${a}${A[i]}concat=n=2:v=0:a=1[y${i}]`);
342
+ len += lens[i].duration;
343
+ }
344
+ v = `[x${i}]`; a = `[y${i}]`;
345
+ }
346
+ // the whip's smear: a horizontal blur over the middle of each slide
347
+ if (smears.length) {
348
+ const on = smears.map(([s, e]) => `between(t\\,${s.toFixed(3)}\\,${e.toFixed(3)})`).join('+');
349
+ g.push(`${v}split[sa][sb];[sb]gblur=sigma=${48}:sigmaV=0.5[sc];[sa][sc]overlay=enable='${on}'[vs]`);
350
+ v = '[vs]';
351
+ }
352
+ const extra = [];
353
+ if (music) {
354
+ extra.push('-i', path.resolve(music));
355
+ const m = files.length;
356
+ g.push(`[${m}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=0:${len.toFixed(3)},afade=t=out:st=${Math.max(0, len - 1).toFixed(3)}:d=1[mu]`);
357
+ g.push(`${a}[mu]amix=inputs=2:normalize=0[am]`);
358
+ a = '[am]';
359
+ }
360
+ g.push(`${a}loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=48000[aout]`);
361
+ g.push(`${v}scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[vout]`);
362
+ await run(FF(), ['-hide_banner', '-y', ...inputs, ...extra, '-filter_complex', g.join(';'), '-map', '[vout]', '-map', '[aout]',
363
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-x264-params', 'aq-mode=3', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
364
+ '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', '-t', len.toFixed(3), out]);
365
+ return { out, seconds: len };
366
+ }
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) {