paper-motion 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/bin/pamo.mjs +222 -21
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +95 -18
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +342 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +531 -0
  51. package/lib/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/render.mjs +13 -9
  54. package/lib/studio.mjs +347 -33
  55. package/package.json +1 -1
  56. package/runtime/index.js +10 -1
  57. package/runtime/player.js +18 -6
  58. package/runtime/swatch.html +21 -0
  59. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  60. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  61. package/studio/index.html +455 -327
package/lib/piece.mjs CHANGED
@@ -1,17 +1,22 @@
1
1
  // A piece's progress: pieces/<piece>/piece.json, shared by the agent (CLI) and the user (pamo studio).
2
2
  //
3
- // stage the stage the piece is at: brief → look → structure → parts → assembly → final
3
+ // stage the stage the piece is at: brief → inventory → video → final. The user sees three steps (What it says,
4
+ // Your video, Export); the brief is settled in the chat.
4
5
  // stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
5
6
  // brief what, who, format, tone, action: written by the agent, editable by the user
6
7
  // parts per part (by its folder in chain.json): status (queued · rendering · review · changes · approved), note
7
8
  // events what the user did in the studio, in order; `seen` is the last one the agent has read
9
+ // ask a question the agent asked in the chat and is waiting on (the studio sends the user there)
10
+ // agent the app or page the agent runs in, so the studio can take the user back to it
8
11
  //
9
12
  // chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
10
13
  import fs from 'node:fs';
11
14
  import path from 'node:path';
12
15
  import { pieceDir } from './chain.mjs';
13
16
 
14
- export const STAGES = ['brief', 'look', 'structure', 'parts', 'assembly', 'final'];
17
+ export const STAGES = ['brief', 'inventory', 'video', 'final'];
18
+ // the stages a piece had before Your video took them over: a piece at one of them is at `video` now
19
+ const OLD_VIDEO = ['look', 'structure', 'parts', 'assembly'];
15
20
  export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
16
21
  export const BRIEF_KEYS = ['what', 'who', 'format', 'tone', 'action'];
17
22
 
@@ -19,7 +24,19 @@ const now = () => new Date().toISOString();
19
24
  const file = (dir) => path.join(dir, 'piece.json');
20
25
 
21
26
  export function readPiece(dir) {
22
- try { return JSON.parse(fs.readFileSync(file(dir), 'utf8')); } catch { return null; }
27
+ try { return upgrade(JSON.parse(fs.readFileSync(file(dir), 'utf8'))); } catch { return null; }
28
+ }
29
+ // a piece started before a stage existed: that stage counts as done when the piece is already past it
30
+ function upgrade(p) {
31
+ if (!p.stages.video) {
32
+ const at = OLD_VIDEO.indexOf(p.stage), last = OLD_VIDEO.findLast((s) => p.stages[s]);
33
+ p.stages.video = at >= 0 ? { ...p.stages[p.stage] } : last && p.stages[last].status === 'approved' && STAGES.indexOf(p.stage) > 0 ? { status: 'approved' } : undefined;
34
+ if (at >= 0) p.stage = 'video';
35
+ for (const s of OLD_VIDEO) delete p.stages[s];
36
+ if (!p.stages.video) delete p.stages.video;
37
+ }
38
+ for (const s of STAGES) p.stages[s] ??= STAGES.indexOf(p.stage) > STAGES.indexOf(s) ? { status: 'approved', skipped: true } : { status: 'pending' };
39
+ return p;
23
40
  }
24
41
 
25
42
  /** Writes atomically (the studio and the CLI both write it). */
@@ -63,8 +80,10 @@ export function eventLine(p, e) {
63
80
  : e.kind === 'changes' ? `changes: "${e.text}"`
64
81
  : e.kind === 'order' ? `order ${e.text}`
65
82
  : e.kind === 'reopen' ? 'reopened'
66
- : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
67
- const tail = e.rejoin ? ' · join again: pamo chain --draft' : '';
83
+ : e.kind === 'failed' ? `could not join the parts: "${e.text}"`
84
+ : e.say ? e.say : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
85
+ // the studio joins the draft again by itself after an edit by hand; the full-quality render stays the agent's
86
+ const tail = `${e.rejoined ? ' (the studio is joining the draft again)' : ''}${e.rejoin ? ' · join again: pamo chain --draft' : ''}${e.refinal ? ` · the final is out of date: render it again (pamo chain ${p.piece} --motion-blur 5), then review final` : ''}`;
68
87
  return `paper-motion: piece ${p.piece} · ${e.kind === 'order' ? '' : `${who} `}${what}${tail}`.replace(/ {2,}/g, ' ');
69
88
  }
70
89
 
@@ -77,15 +96,23 @@ export function addEvent(p, e) {
77
96
  }
78
97
 
79
98
  /** The agent hands a stage to the user: it becomes the current stage, in review. */
80
- export function toReview(dir, stage) {
99
+ export function toReview(dir, stage, { skip = false } = {}) {
81
100
  if (!STAGES.includes(stage)) throw new Error(`stage "${stage}": use ${STAGES.join(', ')}`);
82
101
  return updatePiece(dir, (p) => {
102
+ // nothing moves on until the user approves: an earlier stage they have not approved stops the hand-over,
103
+ // unless the agent says outright that it is skipping it (`skip`), which marks it approved
104
+ const open = earlierOpen(p, stage);
105
+ if (open.length && !skip) throw new Error(`${open.join(', ')} not approved yet: review ${open.length > 1 ? 'them' : 'it'} first (piece review ${p.piece} ${open[0]}), or pass --skip when the user agreed to move on without ${open.length > 1 ? 'them' : 'it'}`);
106
+ for (const s of open) p.stages[s] = { ...p.stages[s], status: 'approved' };
107
+ delete p.ask; // handing a stage over means the question in the chat is settled
83
108
  p.stage = stage;
84
109
  p.stages[stage] = { status: 'review', since: now() };
85
- for (const s of STAGES.slice(0, STAGES.indexOf(stage))) if (p.stages[s].status !== 'approved') p.stages[s] = { ...p.stages[s], status: 'approved' };
86
110
  });
87
111
  }
88
112
 
113
+ /** The stages before `stage` that the user has not approved. */
114
+ export const earlierOpen = (p, stage) => STAGES.slice(0, STAGES.indexOf(stage)).filter((s) => p.stages[s].status !== 'approved');
115
+
89
116
  /** Events the agent hasn't read; marks them read when `mark`. */
90
117
  export function unseen(dir, { mark = true } = {}) {
91
118
  let out = [];
@@ -94,7 +121,7 @@ export function unseen(dir, { mark = true } = {}) {
94
121
  }
95
122
 
96
123
  // an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
97
- const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin;
124
+ const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin || e.refinal;
98
125
 
99
126
  /**
100
127
  * Waits until the user decides on `stage` in the studio (approves it, or asks for changes), then returns every
@@ -102,16 +129,39 @@ const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.
102
129
  */
103
130
  export async function awaitDecision(dir, stage, { timeout = 30 * 60 * 1000, every = 1000, signal } = {}) {
104
131
  const t0 = Date.now();
105
- for (;;) {
106
- const p = readPiece(dir);
107
- const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
108
- if (fresh.some((e) => decisive(e, stage))) {
109
- const r = unseen(dir);
110
- return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) };
132
+ let last = 0;
133
+ try {
134
+ for (;;) {
135
+ if (Date.now() - last > 3000) { beat(dir, { waiting: stage }); last = Date.now(); } // the studio shows "your agent is listening"
136
+ const p = readPiece(dir);
137
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
138
+ if (fresh.some((e) => decisive(e, stage))) {
139
+ const r = unseen(dir);
140
+ return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) };
141
+ }
142
+ if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: fresh, lines: fresh.map((e) => eventLine(p, e)) };
143
+ await new Promise((res) => setTimeout(res, every));
111
144
  }
112
- if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: fresh, lines: fresh.map((e) => eventLine(p, e)) };
113
- await new Promise((res) => setTimeout(res, every));
114
- }
145
+ } finally { unbeat(dir); }
146
+ }
147
+
148
+ // ── the agent's side of the conversation: is it listening, what did it ask, where does it live ────────────────
149
+ // While a command waits for the user (piece review, board), it says so every few seconds in a small file beside
150
+ // piece.json (piece.json itself stays quiet). No fresh beat means nobody reads what the user does in the studio.
151
+ const beatFile = (dir) => path.join(dir, '.agent.json');
152
+ export function beat(dir, info = {}) { try { fs.writeFileSync(beatFile(dir), JSON.stringify({ at: Date.now(), pid: process.pid, ...info })); } catch { /* a read-only project: the studio just won't know */ } }
153
+ export function unbeat(dir) { try { if (JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')).pid === process.pid) fs.rmSync(beatFile(dir)); } catch { /* already gone */ } }
154
+ /** The command waiting on this piece right now, if any. */
155
+ export function listening(dir, within = 9000) { try { const a = JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')); return Date.now() - a.at < within ? a : null; } catch { return null; } }
156
+
157
+ /** The agent asked the user something in the chat: the studio says so until the agent moves on. */
158
+ export function setAsk(dir, text) { return updatePiece(dir, (p) => { p.ask = { text: String(text ?? '').trim(), at: now() }; }); }
159
+ export function clearAsk(dir) { const p = readPiece(dir); return p?.ask ? updatePiece(dir, (q) => { delete q.ask; }) : p; }
160
+ /** Where the agent runs (see host.mjs), kept so the studio can take the user back there. */
161
+ export function setHost(dir, host) {
162
+ const p = readPiece(dir);
163
+ if (!p || !host || JSON.stringify(p.agent ?? null) === JSON.stringify(host)) return p;
164
+ return updatePiece(dir, (q) => { q.agent = host; });
115
165
  }
116
166
 
117
167
  // ── the words on screen: a scene's top-level string CONFIG entries ──────────────────────────
@@ -145,3 +195,29 @@ export function setSceneText(html, key, value) {
145
195
  if (!hit) throw new Error(`CONFIG has no text "${key}"`);
146
196
  return head + next + tail;
147
197
  }
198
+
199
+ // ── boards: the decisions taken on paper-motion.app, listed in the studio so the user finds them there too ──────
200
+ const BOARD_STAGE = () => 'video';
201
+
202
+ /** The piece a board belongs to: the one named, else the only one in progress before Parts. */
203
+ export function activePiece(cwd, name) {
204
+ if (name) { const dir = pieceDir(name, cwd); return readPiece(dir) ? dir : null; }
205
+ const root = path.join(cwd, 'pieces');
206
+ if (!fs.existsSync(root)) return null;
207
+ const live = fs.readdirSync(root).map((d) => path.join(root, d)).filter((d) => { const p = readPiece(d); return p && ['brief', 'inventory', 'video'].includes(p.stage); });
208
+ return live.length === 1 ? live[0] : null;
209
+ }
210
+
211
+ /** Records a board shared for this piece (shown in the studio's Look or Structure stage until chosen). */
212
+ export function attachBoard(dir, { url, id, title, kind }) {
213
+ return updatePiece(dir, (p) => {
214
+ p.boards ??= [];
215
+ if (id && p.boards.some((b) => b.id === id)) return;
216
+ p.boards.push({ url, id: id ?? null, title, kind, stage: BOARD_STAGE(p.stage), at: now(), status: 'waiting' });
217
+ });
218
+ }
219
+
220
+ /** Marks a board chosen with the reply line. */
221
+ export function markBoard(dir, id, line) {
222
+ return updatePiece(dir, (p) => { const b = (p.boards ?? []).find((x) => x.id === id); if (b) { b.status = 'chosen'; b.line = line; } });
223
+ }
package/lib/registry.mjs CHANGED
@@ -110,12 +110,12 @@ export function llmsTxt(items, site, { full = false } = {}) {
110
110
  ## The flow (for agents)
111
111
 
112
112
  1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
113
- ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`. Agents load
113
+ ffmpeg, Chrome). The CLI is \`npx paper-motion\`; where these docs write \`pamo\`, type \`npx paper-motion\` (the npm package named \`pamo\` is unrelated). \`npx paper-motion --version\` prints the version. Every command answers \`--help\`. Agents load
114
114
  skills and MCP servers when a session starts, so after installing the skills **read
115
115
  \`.claude/skills/make-video/SKILL.md\` now** (or \`.agents/skills/make-video/SKILL.md\`) and follow it in this
116
116
  session; tell the user that the MCP at ${site}/mcp, if they want it, is added before opening a session.
117
117
  2. **Open the studio: it is where the user decides.** A video is made in stages the user watches and approves in
118
- a local page: brief, look, structure, parts, assembly, final. Start the piece right after setup:
118
+ a local page in three steps (What it says: brief and inventory; Your video; Export). Start the piece right after setup:
119
119
  \`npx paper-motion piece init <piece> --title "…"\` (it starts the project's studio in the background). Then,
120
120
  at the end of each stage, \`npx paper-motion piece review <piece> <stage>\`: the first time it opens the studio in
121
121
  the user's browser; it waits for their decision and prints what they did, one \`paper-motion: piece …\` line each
@@ -151,7 +151,8 @@ export function llmsTxt(items, site, { full = false } = {}) {
151
151
  the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
152
152
  \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
153
153
  from \`tokens.json\` \`brand\`: write those from the interview before rendering.
154
- 6. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
154
+ 6. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays
155
+ (a chosen transition may be the join between two parts of a chain: \`"join": "wipe"\` in chain.json takes its id);
155
156
  a recipe's own moves are placeholders to swap for theirs. Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
156
157
  \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
157
158
  When the user chose a structure whose beats name blocks or shots (or several blocks in order), chain them instead:
@@ -159,8 +160,13 @@ export function llmsTxt(items, site, { full = false } = {}) {
159
160
  \`pieces/<piece>/<nn>-<id>/\` and writes \`chain.json\` (seconds per part; \`join\`: whip, cut, dissolve or dip);
160
161
  write each part's \`CONFIG\` from its beat, then \`npx paper-motion chain <piece> --motion-blur 5\` renders every
161
162
  part in the brand's look and joins them into one video. It warns when a part is cut before its land.
162
- In the studio: review \`look\` after installing the choices, \`structure\` after \`chain init\`, \`parts\` after
163
- \`chain --drafts\`, \`assembly\` after \`chain --draft\`, \`final\` after the full render.
163
+ In the studio the stages are brief, inventory, video and final. When the user approves the inventory the studio
164
+ builds the video itself from what they kept, and then they edit it there, live (words, how each part arrives,
165
+ backgrounds, settings, lengths, transitions, order, adding and swapping parts, colours, type, pace); every edit
166
+ reaches you as a line. Review \`video\` to wait for their approval; they can render the final from the studio. \`chain --check\` warns when the total drifts from the brief's
167
+ length. Boards you share while a piece is open also appear in its studio (Look or Structure), so the user finds them
168
+ there; if your shell caps how long a command may run, share with \`--no-wait\` and then
169
+ \`npx paper-motion board --await <id> --wait 2\` until it prints the line (exit code 3 = not yet).
164
170
 
165
171
  ## Choosing well: the metadata
166
172
 
@@ -169,6 +175,16 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
169
175
  - \`when\`: 2–3 "use it when…" phrases.
170
176
  - \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
171
177
  - Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
178
+ - Blocks and shots: \`needs\`, the content each one shows by kind (a \`figure\`, a \`change\`, a \`screen\`, a \`command\`, a
179
+ \`line\`…). Choose them by what the user has to show, not by mood. Gather \`pieces/<piece>/inventory.json\` with
180
+ \`npx paper-motion inventory <piece> --from <their site> --from README.md --from brand\` (it reads the page and
181
+ screenshots it, a markdown file, a folder, or the brand's Facts and assets, and writes proposals, each with its
182
+ source; \`npx paper-motion inventory kinds\` lists the kinds and their fields). Complete what a proposal still
183
+ needs (a command's real output), add what gathering cannot see, and hand it over:
184
+ \`npx paper-motion piece review <piece> inventory\`. The user keeps or removes each proposal in the studio; only
185
+ what they kept is used. Then let \`npx paper-motion plan <piece> --seconds <n>\` propose
186
+ the structures: it prints the structure board to share, and \`chain init <piece> --plan <letter>\` installs the
187
+ chosen one with the content already in every part's CONFIG.
172
188
  - Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
173
189
  **Every background is a color family**: a board with a \`look.palette\` shows each background recolored into that
174
190
  palette (each of its colors keeps its tone and takes the nearest palette hue, so a dark one stays dark), and the
@@ -279,7 +295,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
279
295
  for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => typeof p !== 'string' || fs.existsSync(path.join(out, p)))); it.preview = Object.values(have).some((p) => typeof p === 'string') ? have : undefined; }
280
296
  const index = {
281
297
  name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
282
- items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
298
+ items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
283
299
  };
284
300
  fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
285
301
  fs.mkdirSync(path.join(out, 'files'), { recursive: true });
package/lib/render.mjs CHANGED
@@ -94,12 +94,13 @@ export async function renderSfx({ url, out, from = 0, to }) {
94
94
  } finally { await browser.close(); }
95
95
  }
96
96
 
97
- async function sfxFromPage(s, { from = 0, to, out, normalize = true }) {
97
+ async function sfxFromPage(s, { from = 0, to, rate = 1, length, out, normalize = true }) {
98
98
  const { cues, audio } = await s.page.evaluate(() => ({ cues: window.__pm.cues ?? [], audio: window.__pm.audio ?? {} }));
99
99
  if (!cues.length) return null;
100
100
  const end = to ?? s.config.duration;
101
- const shifted = cues.filter((c) => c.t < end).map((c) => ({ ...c, t: c.t - from }));
102
- writeWav(out, synthesize(shifted, { duration: end - from, ...audio, normalize }));
101
+ // a faster or slower part moves its hits with the picture; each sound keeps its own pitch and length
102
+ const shifted = cues.filter((c) => c.t < end).map((c) => ({ ...c, t: (c.t - from) / rate }));
103
+ writeWav(out, synthesize(shifted, { duration: length ?? (end - from) / rate, ...audio, normalize }));
103
104
  return { out, cues: cues.length };
104
105
  }
105
106
 
@@ -110,7 +111,7 @@ function subframes(f, fps, k, shutter, duration) {
110
111
  return Array.from({ length: k }, (_, j) => Math.min(duration - 1e-4, Math.max(0, (f + ((j + 0.5) / k - 0.5) * open) / fps)));
111
112
  }
112
113
 
113
- export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1, from, to, fps: fpsOverride, crf, audio, sfx = true, motionBlur = 1, shutter = 180, gifWidth = 720, gifFps = 24, onProgress }) {
114
+ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1, from, to, rate = 1, seconds, fps: fpsOverride, crf, audio, sfx = true, motionBlur = 1, shutter = 180, gifWidth = 720, gifFps = 24, onProgress }) {
114
115
  const fmt = FORMATS[format];
115
116
  if (!fmt) throw new Error(`Unknown format "${format}". Use one of: ${Object.keys(FORMATS).join(', ')}`);
116
117
  const browser = await launch();
@@ -120,9 +121,12 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
120
121
  const first = await openScene(browser, url, { dpr });
121
122
  const cfg = first.config;
122
123
  const fps = fpsOverride ?? cfg.fps;
124
+ // the scene's window [from, to], played at `rate` (scene seconds per second of video): 2 plays it twice as fast.
125
+ // `seconds` sets the video's length; past the window's end the last frame holds (under an overlapping join)
123
126
  const f0 = Math.round((from ?? 0) * fps);
124
127
  const f1 = Math.round((to ?? cfg.duration) * fps);
125
- const total = f1 - f0;
128
+ const total = seconds !== undefined ? Math.round(seconds * fps) : Math.round((f1 - f0) / rate);
129
+ const sceneT = (st) => Math.min(f1 / fps - 1e-4, f0 / fps + st * rate); // a time in the video → in the scene
126
130
  if (total <= 0) throw new Error('Nothing to render (check --from/--to).');
127
131
  const n = Math.max(1, Math.min(workers ?? Math.min(4, Math.max(1, Math.floor(os.cpus().length / 2))), Math.ceil(total / 15)));
128
132
  const chunk = Math.ceil(total / n);
@@ -131,7 +135,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
131
135
 
132
136
  let sfxWav = null;
133
137
  if (sfx && !fmt.gif && !fmt.seq) {
134
- const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, out: path.join(tmp, 'sfx.wav'), normalize: !audio }); // with an external track, effects keep their own gains
138
+ const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, rate, length: total / fps, out: path.join(tmp, 'sfx.wav'), normalize: !audio }); // with an external track, effects keep their own gains
135
139
  sfxWav = r?.out ?? null;
136
140
  }
137
141
 
@@ -139,7 +143,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
139
143
  for (let i = 1; i < n; i++) sessions.push(await openScene(browser, url, { dpr }));
140
144
 
141
145
  const segments = await Promise.all(sessions.map(async (s, i) => {
142
- const a = f0 + i * chunk, b = Math.min(f1, a + chunk);
146
+ const a = i * chunk, b = Math.min(total, a + chunk);
143
147
  if (a >= b) return null;
144
148
  const seg = path.join(tmp, `seg${String(i).padStart(3, '0')}.mkv`);
145
149
  // Lossless RGB intermediate; the final encode happens once, after concat.
@@ -148,8 +152,8 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
148
152
  const blur = k > 1 ? ['-vf', `tmix=frames=${k},select='eq(mod(n\\,${k})\\,${k - 1})',setpts=N/(${fps}*TB)`, '-r', String(fps)] : [];
149
153
  const p = ffmpeg(['-f', 'image2pipe', '-framerate', String(fps * k), '-c:v', 'png', '-i', '-', ...blur, '-c:v', 'libx264rgb', '-preset', 'ultrafast', '-crf', '0', seg], { stdin: true });
150
154
  for (let f = a; f < b; f++) {
151
- for (const tt of subframes(f, fps, k, shutter, cfg.duration)) {
152
- await s.seek(tt);
155
+ for (const st of subframes(f, fps, k, shutter, Infinity)) {
156
+ await s.seek(sceneT(st));
153
157
  await write(p, await s.capture());
154
158
  }
155
159
  done++; tick();