paper-motion 0.2.7 → 0.3.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.
- package/CHANGELOG.md +11 -0
- package/bin/pamo.mjs +123 -2
- package/lib/chain.mjs +298 -0
- package/lib/live-board.js +3 -1
- package/lib/piece.mjs +147 -0
- package/lib/registry.mjs +27 -9
- package/lib/studio.mjs +258 -0
- package/package.json +2 -1
- package/runtime/brand.js +1 -1
- package/runtime/index.js +1 -0
- package/studio/index.html +391 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
+
## 0.3.0 · 2026-11-11
|
|
6
|
+
|
|
7
|
+
- **Studio**: `pamo studio` serves a local page where the user follows a piece stage by stage (brief, look, structure, parts, assembly, final), watches each stage's renders, approves or asks for changes, and edits the brief, the words on screen (a part's CONFIG texts, re-rendered on the spot), lengths, joins and order (drag and drop). `pieces/<piece>/piece.json` holds the stages and what the user did; `pamo piece init | review | working | status` is the agent's side (`review` hands a stage over and waits for the decision).
|
|
8
|
+
- The studio is the default way to make a piece: `pamo piece init` starts the project's studio in the background (one per project, on the first free port from 4317; `pamo studio --stop` ends it), and the first `pamo piece review` opens it in the user's browser (`--no-open` to skip). `llms.txt` and `make-video` now start every piece there.
|
|
9
|
+
- The studio adapts to tablets and phones.
|
|
10
|
+
- `pamo chain <piece> --drafts [--only dir]` renders each part alone, quickly, for review; `--draft` joins a quick version.
|
|
11
|
+
- **Chains**: `pamo chain init <piece>` installs the blocks of a chosen structure (`--board <id> --option D`, or `--parts id:sec,…`) as one scene each under `pieces/<piece>/` with a `chain.json`; `pamo chain <piece>` renders them in the brand's look and joins them into one video (`whip`, `cut`, `dissolve`, `dip`), keeping each part's sound and setting the loudness once. `--check` warns when a part is cut before its land.
|
|
12
|
+
- Boards: a structure beat may name the block or shot that shows it (`[block-id]` in `--option "@Title | 18 | Hook 0-2 [word-swap] …"`, `id` in the payload, checked against the registry); the board plays that clip for the beat.
|
|
13
|
+
- Scenes expose the gestures they cue (`B.cue`) as `window.__pm.marks`.
|
|
14
|
+
- Blocks with a dark panel keep their type light in light brands (`search-type`, `browser-frame`, `terminal-run`, `light-sweep`); `drag-drop`'s cards follow the brand's mode.
|
|
15
|
+
|
|
5
16
|
## 0.2.7 · 2026-11-10
|
|
6
17
|
|
|
7
18
|
- A new resource type, **blocks**: 29 ready-made 4–8 s moments (annotations, interfaces, code, data, type, brand, effects), installed like recipes and customized through their `CONFIG`; each recomposes for 16:9, 9:16 and 1:1.
|
package/bin/pamo.mjs
CHANGED
|
@@ -45,6 +45,30 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
45
45
|
--cols 1 stacks options full width (judge type at real size)
|
|
46
46
|
--sequence plays the clips one after another, with their sound
|
|
47
47
|
(for choices you hear: sound palettes, typing)
|
|
48
|
+
chain init <piece> (--board <id|link> [--option D] | --parts id:sec,id:sec,…) [--format 1920x1080]
|
|
49
|
+
[--join whip|cut|dissolve|dip] [--title "…"] [--force]
|
|
50
|
+
a piece made of blocks: installs each part as its own scene under
|
|
51
|
+
pieces/<piece>/<nn>-<id>/ (edit each CONFIG) and writes chain.json;
|
|
52
|
+
--board takes the structure the user chose on a board; with neither,
|
|
53
|
+
installs parts you added to chain.json and keeps the rest
|
|
54
|
+
chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--dpr 1] [--check]
|
|
55
|
+
[--drafts [--only dir,…]] [--draft]
|
|
56
|
+
renders every part in the brand's look and joins them into one
|
|
57
|
+
video (run from the project root); --check only plans and warns;
|
|
58
|
+
--drafts renders each part alone, quickly, for the studio's Parts
|
|
59
|
+
stage; --draft joins a quick version for its Assembly stage
|
|
60
|
+
piece init <piece> [--title "…"] [--what "…"] [--who "…"] [--format "…"] [--tone "…"] [--action "…"]
|
|
61
|
+
[--no-studio] start tracking a piece stage by stage (pieces/<piece>/piece.json)
|
|
62
|
+
and start this project's studio in the background
|
|
63
|
+
piece review <piece> <stage> [--wait 30] [--no-wait] [--no-open]
|
|
64
|
+
hand a stage to the user in the studio (brief, look, structure,
|
|
65
|
+
parts, assembly, final) and wait for their decision; prints it.
|
|
66
|
+
Starts the studio if needed; the first time, opens it for them
|
|
67
|
+
piece working <piece> <stage> mark a stage as yours again (after changes were asked)
|
|
68
|
+
piece status <piece> where the piece is, and what the user did that you haven't read
|
|
69
|
+
studio [--port 4317] [--open] [--stop]
|
|
70
|
+
the local studio: the user follows each piece, approves, edits
|
|
71
|
+
(one per project; --stop ends the one running in the background)
|
|
48
72
|
wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking -0.02em]
|
|
49
73
|
[--color #fff] [--size 400] [--pad 0] [--out wordmark.png]
|
|
50
74
|
the name set in its typeface, as a transparent PNG
|
|
@@ -327,13 +351,13 @@ async function main() {
|
|
|
327
351
|
const { parseSet, setArgs } = await import('../lib/customize.mjs');
|
|
328
352
|
const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
|
|
329
353
|
const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
|
|
330
|
-
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action".\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
354
|
+
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
331
355
|
const LABELS = ['A', 'B', 'C', 'D'];
|
|
332
356
|
const parseOne = async (spec) => {
|
|
333
357
|
if (spec.startsWith('@')) {
|
|
334
358
|
// a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
|
|
335
359
|
const [title, dur, beatsSpec] = spec.slice(1).split('|').map((x) => x.trim());
|
|
336
|
-
const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to what"`); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: m[4] }; });
|
|
360
|
+
const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to [block-id] what"`); const sh = m[4].match(/^\[([\w-]+)\]\s*(.+)$/); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: sh ? sh[2] : m[4], ...(sh ? { id: sh[1] } : {}) }; });
|
|
337
361
|
return { structure: { title, duration: Number(dur), beats } };
|
|
338
362
|
}
|
|
339
363
|
const [id, kv] = spec.split(':'), it = index[id];
|
|
@@ -417,6 +441,103 @@ async function main() {
|
|
|
417
441
|
break;
|
|
418
442
|
}
|
|
419
443
|
|
|
444
|
+
case 'chain': {
|
|
445
|
+
const sub = args._[1] === 'init' ? 'init' : 'render';
|
|
446
|
+
const piece = sub === 'init' ? args._[2] : args._[1];
|
|
447
|
+
if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] | --parts id:sec,…] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--check]'); process.exit(args.help ? 0 : 1); }
|
|
448
|
+
const chainLib = await import('../lib/chain.mjs');
|
|
449
|
+
if (sub === 'init') {
|
|
450
|
+
const { SITE } = await import('../lib/registry.mjs');
|
|
451
|
+
let src;
|
|
452
|
+
if (args.board) src = await chainLib.structureFromBoard(String(args.board), typeof args.option === 'string' ? args.option : undefined, { site: typeof args.site === 'string' ? args.site : SITE });
|
|
453
|
+
else if (typeof args.parts === 'string') src = { parts: chainLib.partsFromList(args.parts) };
|
|
454
|
+
else src = {}; // no source: sync an existing chain.json (install parts added since)
|
|
455
|
+
const r = await chainLib.initChain({ piece, title: typeof args.title === 'string' ? args.title : src.title, parts: src.parts, format: typeof args.format === 'string' ? args.format : undefined, join: typeof args.join === 'string' ? args.join : undefined, registry: args.registry, force: !!args.force });
|
|
456
|
+
console.log(`✓ ${path.relative(process.cwd(), r.dir)}/chain.json · "${r.chain.title}" · ${r.chain.parts.length} parts, ${r.chain.format}`);
|
|
457
|
+
for (const p of r.chain.parts) console.log(` ${p.dir ?? '(no block)'}${p.name ? ` · ${p.name}` : ''}${p.seconds ? ` · ${p.seconds} s` : ''}${p.what ? ` — ${p.what}` : ''}`);
|
|
458
|
+
for (const n of r.notes) console.log(` ! ${n}`);
|
|
459
|
+
console.log(`\nNext: write each part's text and data into its CONFIG (the "what" above is the brief), then:\n npx paper-motion chain ${piece} --motion-blur 5`);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
await ensureDeps();
|
|
463
|
+
if (args.check) {
|
|
464
|
+
const r = await chainLib.checkChain({ piece });
|
|
465
|
+
for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.from}–${+(p.from + p.seconds).toFixed(2)} s of ${p.D}`);
|
|
466
|
+
for (const w of r.warnings) console.log(` ! ${w}`);
|
|
467
|
+
console.log(`${r.total.toFixed(1)} s in all`);
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (args.drafts) {
|
|
471
|
+
const only = typeof args.only === 'string' ? args.only.split(',').map((x) => x.trim()) : undefined;
|
|
472
|
+
const r = await chainLib.renderDrafts({ piece, only, onStep: (e) => { if (e.warning) console.log(` ! ${e.warning}`); else process.stdout.write(`\rdraft ${e.part}/${e.of} · ${e.dir} `); } });
|
|
473
|
+
console.log(`\r✓ ${r.rendered.length} draft${r.rendered.length === 1 ? '' : 's'} in ${path.relative(process.cwd(), path.join(chainLib.pieceDir(piece), 'drafts')) || '.'}${r.rendered.length ? '' : ' (every part is approved; --only <dir> to redo one)'} `);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
const blur = args['motion-blur'] ? Number(args['motion-blur'] === true ? 5 : args['motion-blur']) : 1;
|
|
477
|
+
const r = await chainLib.renderChain({ draft: !!args.draft, piece, out: typeof args.out === 'string' ? args.out : undefined, motionBlur: blur, dpr: Number(args.dpr ?? 1), audio: typeof args.audio === 'string' ? args.audio : undefined, sfx: !args['no-sfx'],
|
|
478
|
+
onStep: (e) => { if (e.warning) console.log(` ! ${e.warning}`); else if (e.part) process.stdout.write(`\rpart ${e.part}/${e.of} · ${e.dir} `); else if (e.joining) process.stdout.write('\rjoining… '); } });
|
|
479
|
+
console.log(`\r✓ ${path.relative(process.cwd(), r.out)} — ${r.parts} parts, ${r.total.toFixed(1)} s, ${r.W}×${r.H}`);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
case 'piece': {
|
|
484
|
+
const sub = args._[1], piece = args._[2];
|
|
485
|
+
if (!['init', 'review', 'working', 'status'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …]\n piece review <piece> <stage> [--wait 30] [--no-wait]\n piece working <piece> <stage>\n piece status <piece>'); process.exit(args.help ? 0 : 1); }
|
|
486
|
+
const P = await import('../lib/piece.mjs');
|
|
487
|
+
const { pieceDir } = await import('../lib/chain.mjs');
|
|
488
|
+
const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
|
|
489
|
+
const studio = async () => (args.studio === false || args['no-studio'] ? null : studioLib.ensureStudio(process.cwd(), { bin: fileURLToPath(import.meta.url) }));
|
|
490
|
+
if (sub === 'init') {
|
|
491
|
+
const brief = Object.fromEntries(P.BRIEF_KEYS.filter((k) => typeof args[k] === 'string').map((k) => [k, args[k]]));
|
|
492
|
+
const p = P.initPiece({ piece, title: typeof args.title === 'string' ? args.title : undefined, brief, force: !!args.force });
|
|
493
|
+
console.log(`✓ ${path.relative(process.cwd(), dir)}/piece.json · "${p.title}" · stage ${p.stage}`);
|
|
494
|
+
const missing = P.BRIEF_KEYS.filter((k) => !p.brief[k]);
|
|
495
|
+
if (missing.length) console.log(` brief still empty: ${missing.join(', ')} (pass them, or write piece.json)`);
|
|
496
|
+
const s = await studio().catch((e) => { console.log(` ! ${e.message}`); return null; });
|
|
497
|
+
if (s) console.log(` studio: ${s.url}${q}${s.started ? ' (started in the background; npx paper-motion studio --stop ends it)' : ''}`);
|
|
498
|
+
console.log(`\nNext: npx paper-motion piece review ${piece} brief (opens the studio for the user and waits for their decision)`);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
if (sub === 'status') {
|
|
502
|
+
const p = P.readPiece(dir); if (!p) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
|
|
503
|
+
for (const s of P.STAGES) console.log(`${s === p.stage ? '›' : ' '} ${s.padEnd(10)} ${p.stages[s].status}${p.stages[s].note ? ` “${p.stages[s].note}”` : ''}`);
|
|
504
|
+
for (const [d, x] of Object.entries(p.parts)) console.log(` ${d.padEnd(22)} ${x.status}${x.note ? ` “${x.note}”` : ''}`);
|
|
505
|
+
const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
|
|
506
|
+
if (fresh.length) { console.log(`\n${fresh.length} unread:`); for (const e of fresh) console.log(P.eventLine(p, e)); }
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const stage = args._[3];
|
|
510
|
+
if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
|
|
511
|
+
if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working' }; }); console.log(`✓ ${stage}: working`); break; }
|
|
512
|
+
// review: hand the stage over and wait for the user's decision in the studio
|
|
513
|
+
P.toReview(dir, stage);
|
|
514
|
+
const s = await studio();
|
|
515
|
+
const url = s ? `${s.url}${q}` : null;
|
|
516
|
+
// the first hand-over opens the studio in the user's browser; after that the open page follows the piece
|
|
517
|
+
let opened = false;
|
|
518
|
+
if (url && !args['no-open'] && !P.readPiece(dir).opened) { opened = await studioLib.openInBrowser(url); if (opened) P.updatePiece(dir, (p) => { p.opened = true; }); }
|
|
519
|
+
console.log(`${stage} is ready for the user${url ? `: ${url}${opened ? ' (opened in their browser)' : ' (tell them to open it)'}` : ' (no studio: --no-studio)'}`);
|
|
520
|
+
if (args['no-wait']) { const r = P.unseen(dir); for (const e of r.events) console.log(P.eventLine(r.piece, e)); break; }
|
|
521
|
+
console.log('Waiting for their decision…');
|
|
522
|
+
const r = await P.awaitDecision(dir, stage, { timeout: Number(args.wait ?? 30) * 60 * 1000 });
|
|
523
|
+
for (const l of r.lines) console.log(l);
|
|
524
|
+
if (!r.done) { console.log(`(no decision yet: run piece review ${piece} ${stage} again, or piece status ${piece})`); process.exit(3); }
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case 'studio': {
|
|
529
|
+
const { startStudio, listPieces, findStudio, openInBrowser } = await import('../lib/studio.mjs');
|
|
530
|
+
const running = await findStudio(process.cwd());
|
|
531
|
+
if (args.stop) { if (running) { process.kill(running.pid); console.log(`stopped the studio on port ${running.port}`); } else console.log('no studio running for this project'); break; }
|
|
532
|
+
if (running) { console.log(`studio already running: ${running.url}`); if (args.open) await openInBrowser(running.url); break; }
|
|
533
|
+
const s = await startStudio({ port: Number(args.port ?? 4317) });
|
|
534
|
+
if (args.open) await openInBrowser(`http://127.0.0.1:${s.port}/`);
|
|
535
|
+
const list = listPieces(process.cwd());
|
|
536
|
+
console.log(`studio: http://127.0.0.1:${s.port}/${list.length === 1 ? `?p=${encodeURIComponent(list[0].piece)}` : ''} (${list.length} piece${list.length === 1 ? '' : 's'}; ctrl-c to stop)`);
|
|
537
|
+
await new Promise(() => {});
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
|
|
420
541
|
case 'wordmark': {
|
|
421
542
|
await ensureDeps();
|
|
422
543
|
const text = args._[1];
|
package/lib/chain.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
// Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
|
|
17
|
+
// land (the beat a block declares with B.cue(s, 'land', t)) gets a warning with the length that keeps it.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
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
|
|
25
|
+
const LAND_HOLD = 0.6; // seconds after a land for the hit to read
|
|
26
|
+
|
|
27
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
28
|
+
const pad = (i) => String(i + 1).padStart(2, '0');
|
|
29
|
+
|
|
30
|
+
/** Where a piece lives: a name (pieces/<name>) or a folder that holds chain.json. */
|
|
31
|
+
export function pieceDir(piece, cwd = process.cwd()) {
|
|
32
|
+
const asDir = path.resolve(cwd, piece);
|
|
33
|
+
if (fs.existsSync(path.join(asDir, 'chain.json')) || piece.includes(path.sep) || piece.startsWith('.')) return asDir;
|
|
34
|
+
return path.join(cwd, 'pieces', piece);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parts from a structure (a board option): one part per beat, its block from the beat's id. */
|
|
38
|
+
export function partsFromStructure(st) {
|
|
39
|
+
return st.beats.map((b) => ({ id: b.id ?? null, name: b.name, seconds: +(b.to - b.from).toFixed(2), what: b.what }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parts from "search-type:2.5,browser-frame:3,bar-grow". */
|
|
43
|
+
export function partsFromList(list) {
|
|
44
|
+
return String(list).split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
|
|
45
|
+
const [id, sec] = x.split(':');
|
|
46
|
+
return { id, ...(sec ? { seconds: Number(sec) } : {}) };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A board's structure option, from a short id, a /b/<id> link or a long link (#fragment). */
|
|
51
|
+
export async function structureFromBoard(ref, option, { site }) {
|
|
52
|
+
const { decodeBoard } = await import('./live-board.js');
|
|
53
|
+
let board;
|
|
54
|
+
if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
|
|
55
|
+
else {
|
|
56
|
+
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)`);
|
|
59
|
+
board = (await res.json()).board;
|
|
60
|
+
}
|
|
61
|
+
const opts = board?.options ?? [];
|
|
62
|
+
// --option takes the letter from the reply line (D), or a structure's title; without it, the board's structure
|
|
63
|
+
const letter = option && /^[a-z]$/i.test(option) ? option.toUpperCase() : null;
|
|
64
|
+
const i = letter ? opts.findIndex((o, k) => (o.label ?? String.fromCharCode(65 + k)) === letter)
|
|
65
|
+
: option ? opts.findIndex((o) => o.structure?.title === option) : opts.findIndex((o) => o.structure);
|
|
66
|
+
const o = opts[i];
|
|
67
|
+
if (!o) throw new Error(`board: no option ${option ?? 'with a structure'} (it has ${opts.length})`);
|
|
68
|
+
if (o.structure) return { title: o.structure.title, parts: partsFromStructure(o.structure) };
|
|
69
|
+
return { title: board.title, parts: [{ id: o.id }] }; // a single block or recipe chosen: a chain of one
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Installs each part as its own scene under pieces/<piece>/<nn>-<id>/ (in the requested format) and writes
|
|
74
|
+
* chain.json. Parts with no block (a beat the agent didn't name) are listed with id null for the agent to fill.
|
|
75
|
+
* Without parts, it syncs an existing chain.json: parts whose scene is missing are installed, the rest is kept.
|
|
76
|
+
*/
|
|
77
|
+
export async function initChain({ cwd = process.cwd(), piece, title, parts, format, join, registry, force = false }) {
|
|
78
|
+
const { add, getItem, resolveRegistry } = await import('./registry.mjs');
|
|
79
|
+
const reg = resolveRegistry(registry);
|
|
80
|
+
const dir = pieceDir(piece, cwd), rel = path.relative(path.join(cwd, 'pieces'), dir), file = path.join(dir, 'chain.json');
|
|
81
|
+
const old = readJson(file, null);
|
|
82
|
+
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`);
|
|
83
|
+
if (!old && !parts) throw new Error(`no ${path.relative(cwd, file)} yet: give --board or --parts`);
|
|
84
|
+
const base = parts ? null : old;
|
|
85
|
+
format = format ?? base?.format ?? '1920x1080'; join = join ?? base?.join ?? 'whip';
|
|
86
|
+
if (!JOINS.hasOwnProperty(join)) throw new Error(`join "${join}": use ${Object.keys(JOINS).join(', ')}`);
|
|
87
|
+
const out = [], notes = [];
|
|
88
|
+
for (const [i, p] of (parts ?? old.parts).entries()) {
|
|
89
|
+
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; }
|
|
90
|
+
const sub = p.dir && p.dir.endsWith(`-${p.id}`) ? p.dir : `${pad(i)}-${p.id}`;
|
|
91
|
+
if (base && fs.existsSync(path.join(dir, sub, 'scene.html'))) { out.push({ ...p, dir: sub }); continue; } // installed already: your edits stay
|
|
92
|
+
const it = await getItem(p.id, reg);
|
|
93
|
+
if (it.install?.kind !== 'scene') throw new Error(`${p.id} is a ${it.type}, not a scene (chains take blocks, shots and recipes)`);
|
|
94
|
+
const hasFormat = (it.params ?? []).some((x) => x.name === 'format');
|
|
95
|
+
await add([p.id], { cwd, registry, force, name: path.join(rel, sub), set: hasFormat ? [`format=${format}`] : [] });
|
|
96
|
+
if (!hasFormat && format !== '1920x1080') notes.push(`${p.id} has no format param: it renders at its own size`);
|
|
97
|
+
const { id, name, seconds, what, dir: _, ...rest } = p; // rest: from, join, anything the agent added
|
|
98
|
+
out.push({ id, dir: sub, ...(name ? { name } : {}), ...(seconds ? { seconds } : {}), ...(what ? { what } : {}), ...rest });
|
|
99
|
+
}
|
|
100
|
+
const chain = { ...(base ?? {}), title: title ?? base?.title ?? piece, format, join, parts: out };
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(file, JSON.stringify(chain, null, 2) + '\n');
|
|
103
|
+
const { readPiece, updatePiece } = await import('./piece.mjs');
|
|
104
|
+
if (readPiece(dir)) updatePiece(dir, (p) => {
|
|
105
|
+
const keep = new Set(out.map((x) => x.dir).filter(Boolean));
|
|
106
|
+
for (const d of Object.keys(p.parts)) if (!keep.has(d)) delete p.parts[d];
|
|
107
|
+
for (const d of keep) p.parts[d] ??= { status: 'queued' };
|
|
108
|
+
});
|
|
109
|
+
return { dir, chain, notes };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function run(bin, argv) {
|
|
113
|
+
return new Promise((res, rej) => {
|
|
114
|
+
const p = spawn(bin, argv, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
115
|
+
let err = '';
|
|
116
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
117
|
+
p.on('error', rej);
|
|
118
|
+
p.on('close', (c) => (c === 0 ? res() : rej(new Error(`${bin} failed (${c}): ${err.trim().split('\n').slice(-6).join('\n')}`))));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const FF = () => process.env.PAPER_MOTION_FFMPEG || 'ffmpeg';
|
|
122
|
+
function probe(file) {
|
|
123
|
+
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=codec_type', '-of', 'json', file], { encoding: 'utf8' });
|
|
124
|
+
const j = JSON.parse(r.stdout || '{}');
|
|
125
|
+
return { duration: Number(j.format?.duration ?? 0), audio: (j.streams ?? []).some((s) => s.codec_type === 'audio') };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What a scene declares: its config and the gestures it marks (land, transition, text). */
|
|
129
|
+
async function sceneInfo(browser, url) {
|
|
130
|
+
const { openScene } = await import('./browser.mjs');
|
|
131
|
+
const s = await openScene(browser, url);
|
|
132
|
+
const marks = await s.page.evaluate(() => window.__pm.marks ?? []);
|
|
133
|
+
await s.close();
|
|
134
|
+
return { config: s.config, marks };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Checks a chain and works out each part's window, without rendering. */
|
|
138
|
+
export function plan(chain, infos) {
|
|
139
|
+
const warnings = [];
|
|
140
|
+
const parts = chain.parts.map((p, i) => {
|
|
141
|
+
const info = infos[i], D = info.config.duration, from = p.from ?? 0;
|
|
142
|
+
const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
|
|
143
|
+
if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
|
|
144
|
+
let seconds = p.seconds ?? D - from;
|
|
145
|
+
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; }
|
|
146
|
+
const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
|
|
147
|
+
const land = lands.length ? Math.max(...lands) : null;
|
|
148
|
+
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`);
|
|
149
|
+
return { ...p, from, seconds, join, D };
|
|
150
|
+
});
|
|
151
|
+
// the outgoing part runs on under an overlapping join, as far as it can
|
|
152
|
+
for (const [i, p] of parts.entries()) {
|
|
153
|
+
const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
|
|
154
|
+
p.to = Math.min(p.D, p.from + p.seconds + d);
|
|
155
|
+
}
|
|
156
|
+
return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Renders every part in the brand's look and joins them into one file.
|
|
161
|
+
* Must run from the project root (where brand/ is): scenes load the brand from /brand.
|
|
162
|
+
*/
|
|
163
|
+
export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
|
|
164
|
+
if (draft) { dpr = 0.5; motionBlur = 1; }
|
|
165
|
+
return withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
|
|
166
|
+
const { renderVideo } = await import('./render.mjs');
|
|
167
|
+
for (const w of warnings) onStep({ warning: w });
|
|
168
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
|
|
169
|
+
try {
|
|
170
|
+
const files = [];
|
|
171
|
+
for (const [i, p] of parts.entries()) {
|
|
172
|
+
onStep({ part: i + 1, of: parts.length, dir: p.dir });
|
|
173
|
+
const f = path.join(tmp, `${pad(i)}.mov`);
|
|
174
|
+
await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
|
|
175
|
+
files.push(f);
|
|
176
|
+
}
|
|
177
|
+
const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
|
|
178
|
+
const ground = (tokens.colors?.ground?.hex ?? '#000000').replace('#', '0x');
|
|
179
|
+
const { width: W, height: H, fps } = config;
|
|
180
|
+
out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
|
|
181
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
182
|
+
onStep({ joining: true });
|
|
183
|
+
const j = await join({ files, parts, out, ground, fps, music: audio, lufs: sound.loudness ?? -16 });
|
|
184
|
+
return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
|
|
185
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Renders parts on their own, quickly (half resolution, no motion blur), to pieces/<piece>/drafts/<dir>.mp4: what
|
|
191
|
+
* the user reviews part by part in the studio. Skips approved parts unless named in `only`. Tracks status in piece.json.
|
|
192
|
+
*/
|
|
193
|
+
export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep = () => {} }) {
|
|
194
|
+
const { readPiece, updatePiece } = await import('./piece.mjs');
|
|
195
|
+
return withChain({ cwd, piece }, async ({ dir, url, parts, warnings }) => {
|
|
196
|
+
const { renderVideo } = await import('./render.mjs');
|
|
197
|
+
for (const w of warnings) onStep({ warning: w });
|
|
198
|
+
const tracked = !!readPiece(dir), mark = (d, patch) => tracked && updatePiece(dir, (p) => { p.parts[d] = { ...(p.parts[d] ?? {}), ...patch }; });
|
|
199
|
+
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'));
|
|
200
|
+
for (const p of todo) mark(p.dir, { status: 'queued' });
|
|
201
|
+
fs.mkdirSync(path.join(dir, 'drafts'), { recursive: true });
|
|
202
|
+
const done = [];
|
|
203
|
+
for (const [i, p] of todo.entries()) {
|
|
204
|
+
onStep({ part: i + 1, of: todo.length, dir: p.dir });
|
|
205
|
+
mark(p.dir, { status: 'rendering' });
|
|
206
|
+
const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
|
|
207
|
+
try {
|
|
208
|
+
await renderVideo({ url: url(p), out, from: p.from, to: p.from + p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
|
|
209
|
+
} catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
|
|
210
|
+
const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
|
|
211
|
+
mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
|
|
212
|
+
done.push(out);
|
|
213
|
+
}
|
|
214
|
+
return { rendered: done, warnings };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Plans a chain without rendering: each part's window, its join, and the warnings. */
|
|
219
|
+
export const checkChain = ({ cwd = process.cwd(), piece }) => withChain({ cwd, piece }, async (r) => r);
|
|
220
|
+
|
|
221
|
+
// Serves the project, opens each part once to read its size, duration and marks, and plans the chain.
|
|
222
|
+
async function withChain({ cwd, piece }, fn) {
|
|
223
|
+
const dir = pieceDir(piece, cwd), chain = readJson(path.join(dir, 'chain.json'), null);
|
|
224
|
+
if (!chain) throw new Error(`no chain.json in ${path.relative(cwd, dir) || '.'} (pamo chain init first)`);
|
|
225
|
+
if (!chain.parts?.length) throw new Error('chain.json has no parts');
|
|
226
|
+
const missing = chain.parts.filter((p) => !p.id || !p.dir || !fs.existsSync(path.join(dir, p.dir, 'scene.html')));
|
|
227
|
+
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)`);
|
|
228
|
+
if (!fs.existsSync(path.join(cwd, 'brand'))) throw new Error('no brand/ here: run chain from the project root');
|
|
229
|
+
const { startServer } = await import('./server.mjs');
|
|
230
|
+
const { launch } = await import('./browser.mjs');
|
|
231
|
+
const server = await startServer({ root: cwd });
|
|
232
|
+
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`;
|
|
233
|
+
try {
|
|
234
|
+
const browser = await launch(), infos = [];
|
|
235
|
+
try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
|
|
236
|
+
const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
|
|
237
|
+
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) });
|
|
239
|
+
} finally { await server.close(); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The ffmpeg graph: each part's join applied in turn, sound carried along, loudness set once at the end. */
|
|
243
|
+
async function join({ files, parts, out, ground, fps, music, lufs }) {
|
|
244
|
+
const lens = files.map((f) => probe(f));
|
|
245
|
+
const inputs = files.flatMap((f) => ['-i', f]);
|
|
246
|
+
const g = [];
|
|
247
|
+
// every part gets a sound track (silent if it declares none) so the audio chain lines up
|
|
248
|
+
const A = lens.map((l, i) => {
|
|
249
|
+
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}]`; }
|
|
250
|
+
g.push(`anullsrc=r=48000:cl=stereo,atrim=0:${l.duration.toFixed(3)}[a${i}]`); return `[a${i}]`;
|
|
251
|
+
});
|
|
252
|
+
// a dip fades the parts on either side into the ground color
|
|
253
|
+
const V = parts.map((p, i) => {
|
|
254
|
+
const f = [`fps=${fps}`, 'format=yuv444p10le', 'settb=AVTB', 'setpts=PTS-STARTPTS'];
|
|
255
|
+
const half = JOINS.dip / 2;
|
|
256
|
+
if (parts[i + 1]?.join === 'dip') f.push(`fade=t=out:st=${(lens[i].duration - half).toFixed(3)}:d=${half}:color=${ground}`);
|
|
257
|
+
if (p.join === 'dip') f.push(`fade=t=in:st=0:d=${half}:color=${ground}`);
|
|
258
|
+
g.push(`[${i}:v]${f.join(',')}[v${i}]`);
|
|
259
|
+
return `[v${i}]`;
|
|
260
|
+
});
|
|
261
|
+
let v = V[0], a = A[0], len = lens[0].duration;
|
|
262
|
+
const smears = [];
|
|
263
|
+
for (let i = 1; i < parts.length; i++) {
|
|
264
|
+
const j = parts[i].join, d = JOINS[j];
|
|
265
|
+
if (OVERLAP.has(j)) {
|
|
266
|
+
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}]`);
|
|
268
|
+
g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
|
|
269
|
+
if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
|
|
270
|
+
len = off + lens[i].duration;
|
|
271
|
+
} else {
|
|
272
|
+
g.push(`${v}${V[i]}concat=n=2:v=1:a=0[x${i}]`);
|
|
273
|
+
g.push(`${a}${A[i]}concat=n=2:v=0:a=1[y${i}]`);
|
|
274
|
+
len += lens[i].duration;
|
|
275
|
+
}
|
|
276
|
+
v = `[x${i}]`; a = `[y${i}]`;
|
|
277
|
+
}
|
|
278
|
+
// the whip's smear: a horizontal blur over the middle of each slide
|
|
279
|
+
if (smears.length) {
|
|
280
|
+
const on = smears.map(([s, e]) => `between(t\\,${s.toFixed(3)}\\,${e.toFixed(3)})`).join('+');
|
|
281
|
+
g.push(`${v}split[sa][sb];[sb]gblur=sigma=${48}:sigmaV=0.5[sc];[sa][sc]overlay=enable='${on}'[vs]`);
|
|
282
|
+
v = '[vs]';
|
|
283
|
+
}
|
|
284
|
+
const extra = [];
|
|
285
|
+
if (music) {
|
|
286
|
+
extra.push('-i', path.resolve(music));
|
|
287
|
+
const m = files.length;
|
|
288
|
+
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]`);
|
|
289
|
+
g.push(`${a}[mu]amix=inputs=2:normalize=0[am]`);
|
|
290
|
+
a = '[am]';
|
|
291
|
+
}
|
|
292
|
+
g.push(`${a}loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=48000[aout]`);
|
|
293
|
+
g.push(`${v}scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[vout]`);
|
|
294
|
+
await run(FF(), ['-hide_banner', '-y', ...inputs, ...extra, '-filter_complex', g.join(';'), '-map', '[vout]', '-map', '[aout]',
|
|
295
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-x264-params', 'aq-mode=3', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
296
|
+
'-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', '-t', len.toFixed(3), out]);
|
|
297
|
+
return { out, seconds: len };
|
|
298
|
+
}
|
package/lib/live-board.js
CHANGED
|
@@ -16,8 +16,9 @@ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palet
|
|
|
16
16
|
* look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
|
|
17
17
|
* choose?: number | [number, number], // how many options the user may pick: 1 (default), 3, or [min, max]
|
|
18
18
|
* options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string,
|
|
19
|
-
* structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string }> } }> }} Board
|
|
19
|
+
* structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string, id?: string }> } }> }} Board
|
|
20
20
|
* An option with `structure` (recipe boards only) is a structure the agent proposes for this brief instead of a recipe.
|
|
21
|
+
* A beat's `id` names the block or shot that shows it, so the board plays that clip for the beat instead of a sketch.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
/** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
|
|
@@ -43,6 +44,7 @@ export function validateBoard(b, index) {
|
|
|
43
44
|
if (!(Number(st.duration) > 0)) errs.push(`${at}.structure.duration (seconds) is required`);
|
|
44
45
|
if (!Array.isArray(st.beats) || st.beats.length < 2 || st.beats.length > 8) errs.push(`${at}.structure.beats: 2 to 8 beats`);
|
|
45
46
|
for (const [j, bt] of (st.beats ?? []).entries()) if (!bt?.name || typeof bt.from !== 'number' || typeof bt.to !== 'number' || bt.to <= bt.from || !bt.what) errs.push(`${at}.structure.beats[${j}] needs name, from < to (seconds) and what`);
|
|
47
|
+
for (const [j, bt] of (st.beats ?? []).entries()) if (bt?.id != null && (typeof bt.id !== 'string' || (index && !index[bt.id]))) errs.push(`${at}.structure.beats[${j}].id "${bt.id}" is not in the registry`);
|
|
46
48
|
} else if (!o.id) errs.push(`${at}.id is required`);
|
|
47
49
|
else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
|
|
48
50
|
} else {
|