paper-motion 0.4.0 → 0.5.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 +94 -0
- package/bin/pamo.mjs +63 -6
- package/lib/chain.mjs +20 -11
- package/lib/edit.mjs +51 -7
- package/lib/inventory.mjs +14 -6
- package/lib/piece.mjs +121 -10
- package/lib/registry.mjs +10 -4
- package/lib/render.mjs +13 -9
- package/lib/studio.mjs +195 -7
- package/package.json +2 -1
- package/runtime/brand.js +14 -2
- package/runtime/index.js +10 -1
- package/runtime/player.js +17 -6
- package/studio/index.html +255 -60
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/examples/breath/stills/t000.00.png +0 -0
- package/examples/breath/stills/t002.00.png +0 -0
- package/examples/breath/stills/t004.00.png +0 -0
- package/examples/breath/stills/t006.00.png +0 -0
- package/examples/breath/stills/t008.00.png +0 -0
- package/examples/breath/stills/t010.00.png +0 -0
- package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
- package/examples/chrome-endcard/stills/t000.00.png +0 -0
- package/examples/chrome-endcard/stills/t001.33.png +0 -0
- package/examples/chrome-endcard/stills/t002.67.png +0 -0
- package/examples/chrome-endcard/stills/t004.00.png +0 -0
- package/examples/chrome-endcard/stills/t005.33.png +0 -0
- package/examples/chrome-endcard/stills/t006.67.png +0 -0
- package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
- package/examples/halo-explainer/stills/t000.00.png +0 -0
- package/examples/halo-explainer/stills/t006.00.png +0 -0
- package/examples/halo-explainer/stills/t012.00.png +0 -0
- package/examples/halo-explainer/stills/t018.00.png +0 -0
- package/examples/halo-explainer/stills/t024.00.png +0 -0
- package/examples/halo-explainer/stills/t030.00.png +0 -0
- package/examples/install-loop/stills/contact-sheet.jpg +0 -0
- package/examples/install-loop/stills/t000.00.png +0 -0
- package/examples/install-loop/stills/t001.00.png +0 -0
- package/examples/install-loop/stills/t002.00.png +0 -0
- package/examples/install-loop/stills/t003.00.png +0 -0
- package/examples/install-loop/stills/t004.00.png +0 -0
- package/examples/install-loop/stills/t005.00.png +0 -0
- package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
- package/examples/orbit-keynote/stills/t000.00.png +0 -0
- package/examples/orbit-keynote/stills/t006.50.png +0 -0
- package/examples/orbit-keynote/stills/t013.00.png +0 -0
- package/examples/orbit-keynote/stills/t019.50.png +0 -0
- package/examples/orbit-keynote/stills/t026.00.png +0 -0
- package/examples/orbit-keynote/stills/t032.50.png +0 -0
- package/examples/signal/stills/contact-sheet.jpg +0 -0
- package/examples/signal/stills/t000.00.png +0 -0
- package/examples/signal/stills/t001.67.png +0 -0
- package/examples/signal/stills/t003.33.png +0 -0
- package/examples/signal/stills/t005.00.png +0 -0
- package/examples/signal/stills/t006.67.png +0 -0
- package/examples/signal/stills/t008.33.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,100 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 · 2026-11-13
|
|
6
|
+
|
|
7
|
+
**Studio v2: one page, four steps, an honest agent.** The user makes a piece in the studio alone (What you want to
|
|
8
|
+
say, How it looks, Your video, Export); the agent listens in the background, and its visual choices come into the
|
|
9
|
+
studio instead of links. Five phases, one end-to-end check (`scripts/check-studio.mjs`, 53 checks).
|
|
10
|
+
|
|
11
|
+
Studio v2, phase 1 ([spec](../../docs/spec-studio-v2.md), [plan](../../docs/plan-studio-v2.md)): an honest connection with the agent.
|
|
12
|
+
|
|
13
|
+
- **`piece listen <piece>`**: the agent keeps it running in the background between turns. It returns as soon as the
|
|
14
|
+
user does something that needs the agent (a message, an answer, an approval, a choice), printing it as lines and
|
|
15
|
+
marking them read, or exits 3 after 9 minutes with nothing. The user no longer has to go to the chat to say
|
|
16
|
+
"continue".
|
|
17
|
+
- **The studio only says what it can check about the agent:** listening (a command of the agent is waiting on the
|
|
18
|
+
piece, and its process is alive), working on something (said with `piece working … --on "…"`, for up to 20
|
|
19
|
+
minutes), asking you, or not connected. "Your agent waits" and "tell your agent continue" are gone. A command
|
|
20
|
+
that was killed stops showing as listening at once.
|
|
21
|
+
- **Questions are answered in the studio.** `piece ask` shows the question with an answer box; the answer reaches
|
|
22
|
+
`piece listen` as an `answered "…": "…"` line. **Answered in the chat** closes it.
|
|
23
|
+
- **Ask your agent is a thread:** each message shows Sent, then Read with the time; `piece say <piece> "…"` puts the
|
|
24
|
+
agent's reply there. A message no longer moves the stage to "changes".
|
|
25
|
+
- `scripts/check-studio.mjs` drives a fresh project end to end (headless Chrome for the user, real `pamo` commands
|
|
26
|
+
for the agent).
|
|
27
|
+
|
|
28
|
+
Studio v2, phase 2: one page, four steps, and How it looks.
|
|
29
|
+
|
|
30
|
+
- **A new stage, `look`**, between `inventory` and `video` (`STAGES`: brief, inventory, look, video, final). A
|
|
31
|
+
project whose brand already settles colours, type and a background starts with it done; older pieces get it as
|
|
32
|
+
approved.
|
|
33
|
+
- **The stepper:** What you want to say · How it looks · Your video · Export. Steps ahead stay locked until reached;
|
|
34
|
+
earlier ones reopen. Style moved into How it looks.
|
|
35
|
+
- **How it looks:** colours, type, background and movement (pace, and how words arrive), each a still card. Pointing
|
|
36
|
+
at one plays it in a single live preview of the user's own first sentence (`line-in`, with the look laid over the
|
|
37
|
+
brand only for that preview: `?embed&look=…`, read by `runtime/brand.js`). Choosing writes `brand/`: the palette
|
|
38
|
+
(the brand's library backgrounds take its colours again), the font pair, a colour by hand, the brand's main
|
|
39
|
+
background (added from the library in its colours when needed), pace and the default entrance
|
|
40
|
+
(`brand/motion.json` `entrance`, which `line-in` uses when a part sets none). **Make my video** builds the first
|
|
41
|
+
version, which used to happen on approving the content.
|
|
42
|
+
- **A still of every background** (`apps/web/public/previews/<id>.jpg`, from `scripts/render-backgrounds.mjs`), so
|
|
43
|
+
cards never run a shader.
|
|
44
|
+
|
|
45
|
+
Studio v2, phase 3: the agent's choices come into the studio ([ADR 0005](../../docs/adr/0005-the-studio-replaces-board-links-during-a-piece.md)).
|
|
46
|
+
|
|
47
|
+
- With a piece open and its studio running, **`pamo board --url …` puts the options into the studio** instead of a
|
|
48
|
+
link, on the step they decide: palettes, font pairs, backgrounds and moves in How it looks (kits as a whole look
|
|
49
|
+
at its top), transitions, structures and blocks in Your video. The step shows a dot; the options come first in
|
|
50
|
+
their section, marked **Suggested by your agent**, with the agent's question and notes, and play in the preview
|
|
51
|
+
when pointed at. The command waits (the studio shows the agent listening) and prints the same reply line, built
|
|
52
|
+
with `replyLine`. **None of these** sends what the user would rather see. `--link` makes a link anyway.
|
|
53
|
+
- A palette, font pair or background picked there is applied to `brand/` at once, like any card; moves,
|
|
54
|
+
transitions and structures go to the agent as the line.
|
|
55
|
+
- `board --await c1` waits again for a studio choice, `board --forget c1` takes it out.
|
|
56
|
+
|
|
57
|
+
Studio v2, phase 4: Your video as scenes.
|
|
58
|
+
|
|
59
|
+
- **A list of scenes instead of the strip:** each with its number, a still of itself, what it says and how long it
|
|
60
|
+
plays; drag to reorder; **Add a scene** at the end asks what it is for.
|
|
61
|
+
- **Stills of the real scenes** (the user's words, brand and settings), rendered by the studio one at a time after a
|
|
62
|
+
scene changes (`pieces/<piece>/.stills/`), never while a final renders; until then the library's still shows.
|
|
63
|
+
- **Five tabs:** Text, Animation (with the block's own settings), Background, Speed (length is speed) and Next scene
|
|
64
|
+
(the transition, chosen by watching, or the same between every scene).
|
|
65
|
+
- **Nothing rebuilds by itself.** Something new kept in What you want to say offers **Add it to the video**; approving
|
|
66
|
+
the content again never takes a built piece back to How it looks.
|
|
67
|
+
- **The playing scene no longer restarts when the page redraws** (a still arriving, the agent's state changing): it is
|
|
68
|
+
moved without reloading (`moveBefore`, where the browser has it), and a still only updates its thumbnail.
|
|
69
|
+
|
|
70
|
+
Studio v2, phase 5: light on the machine.
|
|
71
|
+
|
|
72
|
+
- An embedded preview that ended stops its frame loop (a click starts it again): an idle studio draws nothing. The
|
|
73
|
+
check measures it: 0 shader draws and 0 animation frames for 5 s after a scene ends, and at most one live preview
|
|
74
|
+
on the page.
|
|
75
|
+
- **Fixed for npm installs:** the site's `registry.json` lists params by name only, so an installed studio had no
|
|
76
|
+
block settings or entrances to offer (since 0.4) and, now, no palettes' colours, font pairs or backgrounds. It
|
|
77
|
+
fetches the full resource of those (`/r/<id>.json`) when it starts. `scripts/check-studio.mjs` runs against an
|
|
78
|
+
installed package with `PAMO_BIN=…`.
|
|
79
|
+
- The site's making-of, hero and docs page tell the four steps, the five stages behind them, `piece listen` and the
|
|
80
|
+
choices in the studio.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## 0.4.1 · 2026-11-12
|
|
84
|
+
|
|
85
|
+
- **The studio's live previews are much lighter.** A scene opened with `?embed` draws its shaders at the size it is
|
|
86
|
+
shown, not the full frame times the screen's density (a Retina screen drew 8 million pixels a frame for a box
|
|
87
|
+
700 px wide), skips layers no one can see, and plays at 30 frames a second. In a test of a part with six shots,
|
|
88
|
+
shader draws went from 180 to about 10 a second. Renders are unchanged.
|
|
89
|
+
- **A part always plays its whole scene: its length is its speed.** Shorter plays it faster, longer slower, never
|
|
90
|
+
cut short and never looped; sounds move with the picture and keep their pitch. Each part may run from 1.6× faster
|
|
91
|
+
to 0.6× slower than its scene (`SPEED`, `lengthRange` in inventory.mjs); a length outside that range is held at
|
|
92
|
+
the limit with a warning. The studio's Length slider runs over that range, says how much faster or slower the
|
|
93
|
+
part plays, and its live preview plays at that speed. The planner proposes lengths inside the range.
|
|
94
|
+
- **Previews in the studio play once and hold their last frame** (a click plays one again); "Play the whole video"
|
|
95
|
+
moves on when each part ends, instead of after a timer that let a part loop.
|
|
96
|
+
- **Beat cuts puts one word on each beat, never repeating, and ends on its last word**: its length is the words
|
|
97
|
+
at the tempo. The chain records each scene's real length, so the studio knows it.
|
|
98
|
+
|
|
5
99
|
## 0.4.0 · 2026-11-12
|
|
6
100
|
|
|
7
101
|
A piece is built from what the user has to show, not from a mood.
|
package/bin/pamo.mjs
CHANGED
|
@@ -364,7 +364,7 @@ async function main() {
|
|
|
364
364
|
const { parseSet, setArgs } = await import('../lib/customize.mjs');
|
|
365
365
|
const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
|
|
366
366
|
const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
|
|
367
|
-
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\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, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
367
|
+
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url] [--link] (with a piece open and its studio running the options go into the studio, not a link; --link makes a link anyway)\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\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, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
368
368
|
const LABELS = ['A', 'B', 'C', 'D'];
|
|
369
369
|
// commas only separate at the top level (not inside [..], {..} or quotes), and only before a new "key=":
|
|
370
370
|
// so ramp=["#000","#111"] and multi-value colors survive
|
|
@@ -420,6 +420,31 @@ async function main() {
|
|
|
420
420
|
} else if (typeof args.look === 'string') for (const spec of splitLook(args.look)) { const o = await parseOne(spec); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair (or "brand"), not "${o.id}"`); }
|
|
421
421
|
const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
|
|
422
422
|
const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), ...(choose !== undefined ? { choose } : {}), options };
|
|
423
|
+
// a piece open with its studio running: the options go into the studio, on the step they decide, not into a
|
|
424
|
+
// link; the command waits there and prints the same reply line (ADR 0005). --link makes a site link anyway.
|
|
425
|
+
if (!args.link) {
|
|
426
|
+
const PCs = await import('../lib/piece.mjs');
|
|
427
|
+
const pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
|
|
428
|
+
const st = pdirS ? await (await import('../lib/studio.mjs')).findStudio(process.cwd()) : null;
|
|
429
|
+
if (st) {
|
|
430
|
+
const { validateBoard } = await import('../lib/live-board.js');
|
|
431
|
+
const errs = validateBoard(board, index); if (errs.length) { console.error(`✗ ${errs.join('; ')}`); process.exit(1); }
|
|
432
|
+
const type = board.kind === 'kit' ? 'kit' : board.options[0].structure ? 'structure' : index[board.options[0].id]?.type;
|
|
433
|
+
const PLACE = { palette: ['look', 'colours', 'How it looks · Colours'], 'font-pair': ['look', 'type', 'How it looks · Type'], background: ['look', 'background', 'How it looks · Background'],
|
|
434
|
+
text: ['look', 'movement', 'How it looks · Movement'], component: ['look', 'movement', 'How it looks · Movement'], camera: ['look', 'movement', 'How it looks · Movement'], kit: ['look', 'kit', 'How it looks'],
|
|
435
|
+
transition: ['video', 'joins', 'Your video'], structure: ['video', 'structure', 'Your video'], recipe: ['video', 'structure', 'Your video'], block: ['video', 'scenes', 'Your video'], shot: ['video', 'scenes', 'Your video'] };
|
|
436
|
+
const [step, section, label] = PLACE[type] ?? ['video', 'other', 'Your video'];
|
|
437
|
+
const cid = PCs.addChoice(pdirS, { board, where: { step, section, type } });
|
|
438
|
+
console.log(`in the studio, on ${label}: ${st.url}?p=${encodeURIComponent(path.basename(pdirS))} (choice ${cid}; no link to share)`);
|
|
439
|
+
if (args['no-wait']) { console.error(`the user chooses in the studio; wait for it with: board --await ${cid}`); break; }
|
|
440
|
+
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
|
|
441
|
+
console.error(`waiting up to ${minutes} min for their choice in the studio…`);
|
|
442
|
+
const line = await PCs.awaitChoiceIn(pdirS, cid, { timeout: minutes * 60 * 1000 });
|
|
443
|
+
if (!line) { console.error(`no choice yet: wait again with board --await ${cid}`); process.exit(3); }
|
|
444
|
+
console.log(line);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
423
448
|
const site = typeof args.site === 'string' ? args.site : SITE;
|
|
424
449
|
let frag, short = null;
|
|
425
450
|
try { frag = await encodeBoard(board, index); } catch (e) { console.error(e.message); process.exit(1); }
|
|
@@ -443,6 +468,22 @@ async function main() {
|
|
|
443
468
|
} else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
|
|
444
469
|
break;
|
|
445
470
|
}
|
|
471
|
+
if (args.await && /^c\d+$/.test(String(args.await))) { // a choice in the studio
|
|
472
|
+
const PCs = await import('../lib/piece.mjs'), pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
|
|
473
|
+
if (!pdirS) { console.error('no open piece here (pass --piece)'); process.exit(1); }
|
|
474
|
+
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
|
|
475
|
+
console.error(`waiting up to ${minutes} min for their choice in the studio…`);
|
|
476
|
+
const line = await PCs.awaitChoiceIn(pdirS, String(args.await), { timeout: minutes * 60 * 1000 });
|
|
477
|
+
if (!line) { console.error('no choice yet: run it again'); process.exit(3); }
|
|
478
|
+
console.log(line);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
if (args.forget && /^c\d+$/.test(String(args.forget))) {
|
|
482
|
+
const PCs = await import('../lib/piece.mjs'), pdirS = PCs.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
|
|
483
|
+
if (!pdirS) { console.error('no open piece here (pass --piece)'); process.exit(1); }
|
|
484
|
+
PCs.forgetChoice(pdirS, String(args.forget)); console.log(`took choice ${args.forget} out of the studio`);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
446
487
|
if (args.await) {
|
|
447
488
|
// wait (again) for the choice on a board already shared: for shells that cap how long one command may run
|
|
448
489
|
const { awaitChoice } = await import('../lib/live-board.js');
|
|
@@ -550,7 +591,7 @@ async function main() {
|
|
|
550
591
|
await ensureDeps();
|
|
551
592
|
if (args.check) {
|
|
552
593
|
const r = await chainLib.checkChain({ piece });
|
|
553
|
-
for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.
|
|
594
|
+
for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.seconds} s (its ${p.natural} s scene${Math.abs(p.rate - 1) > 0.005 ? `, ×${p.rate.toFixed(2)}` : ''})`);
|
|
554
595
|
for (const w of r.warnings) console.log(` ! ${w}`);
|
|
555
596
|
console.log(`${r.total.toFixed(1)} s in all`);
|
|
556
597
|
break;
|
|
@@ -657,7 +698,7 @@ async function main() {
|
|
|
657
698
|
|
|
658
699
|
case 'piece': {
|
|
659
700
|
const sub = args._[1], piece = args._[2];
|
|
660
|
-
if (!['init', 'review', 'working', 'status', 'ask'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece working <piece> <stage
|
|
701
|
+
if (!['init', 'review', 'working', 'status', 'ask', 'listen', 'say'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece listen <piece> [--wait 9 (minutes)]\n piece working <piece> <stage> [--on "what you are doing, in plain words"]\n piece say <piece> "a short reply the user reads in the studio"\n piece ask <piece> "the question" | --done\n piece status <piece>\n listen: run it in the background between your turns; it returns as soon as the user does something that needs you (a message, an answer, an approval, a choice), printing it as lines, or after --wait minutes with nothing (exit 3: start it again). While it runs the studio says you are listening.\n ask: run it right before you ask the user anything in the chat, so the studio sends them there (the next review or working clears it, or --done). --return <url> on any of these gives the studio a link back to you when you run in a browser.\n Stages: brief, inventory (what the video can show), video (they edit it in the studio), final. review hands a stage to the user in the studio and prints what they did, one line each; it refuses while an earlier stage is not approved (--skip moves on without it, only when the user agreed). --no-wait returns at once with what is unread; --no-open does not open their browser; --no-studio runs without the studio.'); process.exit(args.help ? 0 : 1); }
|
|
661
702
|
const P = await import('../lib/piece.mjs');
|
|
662
703
|
const { pieceDir } = await import('../lib/chain.mjs');
|
|
663
704
|
const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
|
|
@@ -689,15 +730,31 @@ async function main() {
|
|
|
689
730
|
await host();
|
|
690
731
|
if (args.done) { P.clearAsk(dir); console.log('✓ cleared: the studio no longer sends the user to the chat'); break; }
|
|
691
732
|
const question = typeof args._[3] === 'string' ? args._[3].trim() : '';
|
|
692
|
-
if (!question) { console.error('piece ask: give the question in one line
|
|
733
|
+
if (!question) { console.error('piece ask: give the question in one line, or --done'); process.exit(1); }
|
|
693
734
|
P.setAsk(dir, question);
|
|
694
|
-
console.log(`✓ the studio
|
|
735
|
+
console.log(`✓ the studio shows your question with a box to answer it; their answer reaches piece listen ${piece} as an "answered" line. Ask it in the chat too. It closes when they answer, or with the next piece review or piece working (or piece ask ${piece} --done).`);
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
if (sub === 'listen') {
|
|
739
|
+
if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
|
|
740
|
+
await host();
|
|
741
|
+
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 9;
|
|
742
|
+
const r = await P.listenFor(dir, { timeout: minutes * 60 * 1000 });
|
|
743
|
+
for (const l of r.lines) console.log(l);
|
|
744
|
+
if (!r.done) { console.log(`(nothing from the user in ${minutes} min: run piece listen ${piece} again)`); process.exit(3); }
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
if (sub === 'say') {
|
|
748
|
+
if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
|
|
749
|
+
const text = typeof args._[3] === 'string' ? args._[3].trim() : '';
|
|
750
|
+
if (!text) { console.error('piece say: give the reply in one or two plain sentences'); process.exit(1); }
|
|
751
|
+
P.addNote(dir, text); console.log('✓ the user sees it in the studio, under Ask your agent');
|
|
695
752
|
break;
|
|
696
753
|
}
|
|
697
754
|
const stage = args._[3];
|
|
698
755
|
await host();
|
|
699
756
|
if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
|
|
700
|
-
if (sub === 'working') { P.updatePiece(dir, (p) => {
|
|
757
|
+
if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working', since: new Date().toISOString() }; }); P.setWorking(dir, typeof args.on === 'string' ? args.on : null); console.log(`✓ ${stage}: working${typeof args.on === 'string' ? ` on ${args.on}` : ''} (the studio shows it until you listen or review, or for 20 minutes)`); break; }
|
|
701
758
|
// review: hand the stage over and wait for the user's decision in the studio
|
|
702
759
|
if (stage === 'inventory') {
|
|
703
760
|
// the user is asked whether each thing is true and worth showing: they cannot judge half an item
|
package/lib/chain.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { spawn, spawnSync } from 'node:child_process';
|
|
25
|
+
import { lengthRange } from './inventory.mjs';
|
|
25
26
|
|
|
26
27
|
export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
|
|
27
28
|
// the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
|
|
@@ -35,7 +36,6 @@ export const MOVE_JOINS = {
|
|
|
35
36
|
for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
|
|
36
37
|
JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
|
|
37
38
|
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
39
|
|
|
40
40
|
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
41
41
|
const pad = (i) => String(i + 1).padStart(2, '0');
|
|
@@ -156,17 +156,20 @@ export function plan(chain, infos) {
|
|
|
156
156
|
const info = infos[i], D = info.config.duration, from = p.from ?? 0;
|
|
157
157
|
const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
|
|
158
158
|
if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
// a part plays its whole scene: another length plays it faster or slower (never cut, never looped), within limits
|
|
160
|
+
const natural = +(D - from).toFixed(3), range = lengthRange(natural);
|
|
161
|
+
let seconds = p.seconds ?? natural;
|
|
162
|
+
if (seconds < range.min - 1e-6 || seconds > range.max + 1e-6) {
|
|
163
|
+
const held = Math.min(range.max, Math.max(range.min, seconds));
|
|
164
|
+
warnings.push(`${p.dir}: ${seconds} s would play its ${natural} s scene too ${seconds < range.min ? 'fast' : 'slow'} (it takes ${range.min}–${range.max} s); it plays ${held} s`);
|
|
165
|
+
seconds = held;
|
|
166
|
+
}
|
|
167
|
+
return { ...p, from, seconds, join, D, natural, rate: natural / seconds };
|
|
165
168
|
});
|
|
166
|
-
// the outgoing part
|
|
169
|
+
// under an overlapping join the outgoing part holds its last frame while the next one comes in
|
|
167
170
|
for (const [i, p] of parts.entries()) {
|
|
168
171
|
const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
|
|
169
|
-
p.
|
|
172
|
+
p.out = +(p.seconds + d).toFixed(3);
|
|
170
173
|
}
|
|
171
174
|
return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
|
|
172
175
|
}
|
|
@@ -232,7 +235,7 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
|
|
|
232
235
|
onStep({ part: i + 1, of: parts.length, dir: p.dir });
|
|
233
236
|
if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
|
|
234
237
|
const f = path.join(tmp, `${pad(i)}.mov`);
|
|
235
|
-
await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.
|
|
238
|
+
await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.D, rate: p.rate, seconds: p.out, sfx, motionBlur, dpr });
|
|
236
239
|
files.push(f);
|
|
237
240
|
}
|
|
238
241
|
const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
|
|
@@ -268,7 +271,7 @@ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep =
|
|
|
268
271
|
mark(p.dir, { status: 'rendering' });
|
|
269
272
|
const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
|
|
270
273
|
try {
|
|
271
|
-
await renderVideo({ url: url(p), out, from: p.from, to: p.
|
|
274
|
+
await renderVideo({ url: url(p), out, from: p.from, to: p.D, rate: p.rate, seconds: p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
|
|
272
275
|
} catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
|
|
273
276
|
const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
|
|
274
277
|
mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
|
|
@@ -296,6 +299,12 @@ async function withChain({ cwd, piece }, fn) {
|
|
|
296
299
|
try {
|
|
297
300
|
const browser = await launch(), infos = [];
|
|
298
301
|
try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
|
|
302
|
+
// what each scene really lasts (some depend on their content: one beat per word), for the studio's length slider
|
|
303
|
+
try {
|
|
304
|
+
fs.writeFileSync(path.join(dir, '.lengths.json'), JSON.stringify(Object.fromEntries(chain.parts.map((p, i) => [p.dir, {
|
|
305
|
+
duration: infos[i].config.duration, mtime: Math.round(fs.statSync(path.join(dir, p.dir, 'scene.html')).mtimeMs),
|
|
306
|
+
}])), null, 2) + '\n');
|
|
307
|
+
} catch { /* the slider falls back to the block's usual length */ }
|
|
299
308
|
const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
|
|
300
309
|
if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
|
|
301
310
|
const planned = plan(chain, infos);
|
package/lib/edit.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import vm from 'node:vm';
|
|
12
|
-
import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe } from './inventory.mjs';
|
|
12
|
+
import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe, lengthRange } from './inventory.mjs';
|
|
13
13
|
|
|
14
14
|
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
15
15
|
const writeJson = (f, v) => fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n');
|
|
@@ -106,16 +106,29 @@ export const canUndo = (dir) => readJson(histFile(dir), []).at(-1)?.label ?? nul
|
|
|
106
106
|
|
|
107
107
|
// ── reading the video ──────────────────────────────────────────────────────────────────────────────────────────
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* A part's length within what its scene allows: it always plays the whole scene, so a length is a speed. Returns the
|
|
111
|
+
* length (held inside the range), the scene's own length, the range and the speed (above 1 is faster).
|
|
112
|
+
*/
|
|
113
|
+
export function timing(seconds, natural) {
|
|
114
|
+
const range = lengthRange(natural), s = Math.min(range.max, Math.max(range.min, seconds ?? natural));
|
|
115
|
+
return { seconds: s, duration: natural, range: { min: range.min, max: range.max }, rate: +(natural / s).toFixed(3) };
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/** The parts as the editor shows them: each with its block, its words, motion, background, settings and join. */
|
|
110
119
|
export function videoView(cwd, dir, scenes) {
|
|
111
120
|
const chain = readJson(path.join(dir, 'chain.json'), null);
|
|
112
121
|
if (!chain) return null;
|
|
113
122
|
const byId = new Map(scenes.map((b) => [b.id, b]));
|
|
123
|
+
// what each scene really lasts, read by the last join (a scene's length can depend on its content); stale once edited
|
|
124
|
+
const lengths = readJson(path.join(dir, '.lengths.json'), {});
|
|
125
|
+
const real = (p) => { const l = lengths[p.dir]; try { return l && l.mtime === Math.round(fs.statSync(partFile(dir, p)).mtimeMs) ? l.duration : null; } catch { return null; } };
|
|
114
126
|
const parts = (chain.parts ?? []).filter((p) => p.dir && fs.existsSync(partFile(dir, p))).map((p, i, all) => {
|
|
115
127
|
const html = fs.readFileSync(partFile(dir, p), 'utf8'), cfg = readConfig(html), b = byId.get(p.id) ?? { id: p.id, params: [], needs: [] };
|
|
128
|
+
const D = real(p);
|
|
116
129
|
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
130
|
return {
|
|
118
|
-
dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '',
|
|
131
|
+
dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', ...timing(p.seconds, D ?? b.duration ?? 4), exact: D !== null,
|
|
119
132
|
join: i < all.length - 1 ? p.join ?? chain.join ?? 'whip' : null, shows: p.shows ?? [], what: p.what ?? '',
|
|
120
133
|
words: wordFields(b, cfg), motion, background: cfg.background ?? null, takesBackground: (b.params ?? []).some((x) => x.name === 'background'), tune: tunables(b, cfg),
|
|
121
134
|
mtime: Math.round(fs.statSync(partFile(dir, p)).mtimeMs),
|
|
@@ -193,8 +206,11 @@ export async function editVideo(cwd, dir, a, scenes, brief = {}) {
|
|
|
193
206
|
}
|
|
194
207
|
case 'seconds': {
|
|
195
208
|
const p = find(), n = Number(a.value); if (!Number.isFinite(n)) throw new Error('give a length in seconds');
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
const b = byId.get(p.id) ?? {}, natural = readJson(path.join(dir, '.lengths.json'), {})[p.dir]?.duration ?? b.duration ?? 4;
|
|
210
|
+
const t = timing(+n.toFixed(1), natural);
|
|
211
|
+
keep([chainFile], `the length of ${title(p)}`); p.seconds = t.seconds; writeJson(chainFile, chain);
|
|
212
|
+
const pace = t.rate > 1.005 ? `, ${Math.round((t.rate - 1) * 100)}% faster` : t.rate < 0.995 ? `, ${Math.round((1 - t.rate) * 100)}% slower` : '';
|
|
213
|
+
return { say: `${title(p)}: ${p.seconds} s${pace}`, part: p.dir };
|
|
198
214
|
}
|
|
199
215
|
case 'join': {
|
|
200
216
|
const { JOINS } = await import('./chain.mjs'); if (!(a.value in JOINS)) throw new Error('bad transition');
|
|
@@ -304,19 +320,47 @@ export async function ensureLook(cwd, name) {
|
|
|
304
320
|
return installed;
|
|
305
321
|
}
|
|
306
322
|
|
|
323
|
+
// the brand's backgrounds that come from the library take its colours again (after the palette or a colour changed)
|
|
324
|
+
async function recolorBackgrounds(cwd) {
|
|
325
|
+
const setFile = path.join(cwd, 'brand/shader-set/set.json'), set = readJson(setFile, {});
|
|
326
|
+
const { add, getIndex } = await import('./registry.mjs');
|
|
327
|
+
const index = new Set(((await getIndex().catch(() => ({ items: [] }))).items ?? []).filter((x) => x.type === 'background').map((x) => x.id));
|
|
328
|
+
const ids = (set.entries ?? []).filter((e) => e.shader && !e.image && index.has(e.id)).map((e) => e.id);
|
|
329
|
+
if (!ids.length) return;
|
|
330
|
+
const order = (set.entries ?? []).map((e) => e.id);
|
|
331
|
+
await add(ids, { cwd, palette: 'brand', force: true });
|
|
332
|
+
const after = readJson(setFile, {}); // keep the order the user had: the first is the brand's background
|
|
333
|
+
after.entries = [...(after.entries ?? [])].sort((x, y) => (order.indexOf(x.id) + 1 || 1e9) - (order.indexOf(y.id) + 1 || 1e9));
|
|
334
|
+
writeJson(setFile, after);
|
|
335
|
+
}
|
|
336
|
+
|
|
307
337
|
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');
|
|
338
|
+
const tokens = path.join(cwd, 'brand/tokens.json'), motion = path.join(cwd, 'brand/motion.json'), setFile = path.join(cwd, 'brand/shader-set/set.json');
|
|
339
|
+
snapshot(cwd, dir, [tokens, motion, path.join(cwd, 'brand/fonts.css'), setFile], 'the style');
|
|
310
340
|
if (a.type === 'palette' || a.type === 'font') {
|
|
311
341
|
const { add } = await import('./registry.mjs');
|
|
312
342
|
await add([a.value], { cwd, force: true });
|
|
343
|
+
if (a.type === 'palette') await recolorBackgrounds(cwd);
|
|
313
344
|
return { say: `${a.type === 'palette' ? 'Colours' : 'Type'}: ${a.value}` };
|
|
314
345
|
}
|
|
315
346
|
if (a.type === 'color') {
|
|
316
347
|
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);
|
|
348
|
+
const t = readJson(tokens, {}); t.colors ??= {}; t.colors[a.key] = { ...(t.colors[a.key] ?? {}), hex: a.value.toUpperCase() }; delete t.ramp; writeJson(tokens, t);
|
|
349
|
+
await recolorBackgrounds(cwd);
|
|
318
350
|
return { say: `${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[a.key]} colour ${a.value}` };
|
|
319
351
|
}
|
|
352
|
+
if (a.type === 'background') { // the brand's background: the first entry of its set, added from the library in its colours
|
|
353
|
+
let set = readJson(setFile, { entries: [] });
|
|
354
|
+
if (!(set.entries ?? []).some((e) => e.id === a.value)) { const { add } = await import('./registry.mjs'); await add([a.value], { cwd, palette: 'brand' }); set = readJson(setFile, { entries: [] }); }
|
|
355
|
+
const i = set.entries.findIndex((e) => e.id === a.value); if (i < 0) throw new Error('no such background');
|
|
356
|
+
set.entries.unshift(...set.entries.splice(i, 1)); writeJson(setFile, set);
|
|
357
|
+
return { say: `Background: ${a.value}` };
|
|
358
|
+
}
|
|
359
|
+
if (a.type === 'entrance') { // how words arrive, unless a part says otherwise
|
|
360
|
+
if (!/^[a-z-]+$/.test(a.value)) throw new Error('bad entrance');
|
|
361
|
+
const m = readJson(motion, {}); m.entrance = a.value; writeJson(motion, m);
|
|
362
|
+
return { say: `Words arrive: ${a.value}` };
|
|
363
|
+
}
|
|
320
364
|
if (a.type === 'pace') {
|
|
321
365
|
if (!['calm', 'medium', 'fast'].includes(a.value)) throw new Error('bad pace');
|
|
322
366
|
const m = readJson(motion, {}); m.pace = a.value; writeJson(motion, m);
|
package/lib/inventory.mjs
CHANGED
|
@@ -281,7 +281,14 @@ const lead = (b) => b.needs[0];
|
|
|
281
281
|
// the shortest a scene can run in a chain: its land (where its last beat, the hold, begins) plus the time for the
|
|
282
282
|
// hit to read; a scene that ends on something else keeps most of its length
|
|
283
283
|
const LAND_HOLD = 0.6;
|
|
284
|
-
|
|
284
|
+
/**
|
|
285
|
+
* A part always plays its whole scene: a different length plays it faster or slower, never cut short and never
|
|
286
|
+
* looped. These are how far it may go, so motion still reads (too fast) and still moves (too slow).
|
|
287
|
+
*/
|
|
288
|
+
export const SPEED = { fast: 1.6, slow: 0.6 };
|
|
289
|
+
/** The lengths a part may take, from its scene's own length: { natural, min, max } in seconds. */
|
|
290
|
+
export const lengthRange = (natural) => ({ natural, min: Math.ceil((natural / SPEED.fast) * 10) / 10, max: Math.floor((natural / SPEED.slow) * 10) / 10 });
|
|
291
|
+
const floorOf = (b) => lengthRange(b.duration ?? 5).min; // as fast as it may play
|
|
285
292
|
const fits = (need, it) => it.kind === need.kind && (need.has ?? []).every((f) => it[f] !== undefined && it[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => it[k] === v);
|
|
286
293
|
// an item that says otherwise ("device: phone" for a block that prefers desktop) goes to this block last
|
|
287
294
|
const offKey = (need, it) => Object.entries(need.prefer ?? {}).some(([k, v]) => it[k] !== undefined && it[k] !== v);
|
|
@@ -411,9 +418,9 @@ export function propose(items, scenes, { seconds, brief = {} } = {}) {
|
|
|
411
418
|
return options.map((o, i) => ({ letter: String.fromCharCode(65 + i), ...o }));
|
|
412
419
|
}
|
|
413
420
|
|
|
414
|
-
// Fits the beats to the length asked for.
|
|
415
|
-
// is too long loses its last beat that is not a must instead
|
|
416
|
-
//
|
|
421
|
+
// Fits the beats to the length asked for. Every beat plays its whole scene, faster or slower: none faster than its
|
|
422
|
+
// floor (SPEED.fast), so a piece that is too long loses its last beat that is not a must instead. A short piece plays
|
|
423
|
+
// a little slower (up to a quarter), never more: padding it further would be a lie about the content.
|
|
417
424
|
function timed(beats, target) {
|
|
418
425
|
let list = beats.map((b) => ({ ...b })), notes = [];
|
|
419
426
|
const sum = (k) => list.reduce((s, b) => s + b[k], 0);
|
|
@@ -425,8 +432,9 @@ function timed(beats, target) {
|
|
|
425
432
|
notes.push(`dropped "${list[i].what}" to fit ${target} s`); list.splice(i, 1);
|
|
426
433
|
}
|
|
427
434
|
// share out what is left over the floors; a scene never runs past its own length, so a short piece stays short
|
|
428
|
-
const
|
|
429
|
-
list.forEach((b) => { b.duration = +(b.
|
|
435
|
+
const total = sum('duration'), room = total - sum('min');
|
|
436
|
+
if (target >= total) { const g = Math.min(1.25, target / (total || 1)); list.forEach((b) => { b.duration = +(b.duration * g).toFixed(1); }); }
|
|
437
|
+
else { const k = Math.max(0, (target - sum('min')) / (room || 1)); list.forEach((b) => { b.duration = +(b.min + (b.duration - b.min) * k).toFixed(1); }); }
|
|
430
438
|
if (sum('duration') > target * 1.05) notes.push(`runs ${sum('duration').toFixed(1)} s, over the ${target} s asked: every beat left is a must and none can run shorter`);
|
|
431
439
|
if (sum('duration') < target * 0.9) notes.push(`runs ${sum('duration').toFixed(1)} s of the ${target} s asked: there is no more material to show`);
|
|
432
440
|
}
|