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.
Files changed (56) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/bin/pamo.mjs +63 -6
  3. package/lib/chain.mjs +20 -11
  4. package/lib/edit.mjs +51 -7
  5. package/lib/inventory.mjs +14 -6
  6. package/lib/piece.mjs +121 -10
  7. package/lib/registry.mjs +10 -4
  8. package/lib/render.mjs +13 -9
  9. package/lib/studio.mjs +195 -7
  10. package/package.json +2 -1
  11. package/runtime/brand.js +14 -2
  12. package/runtime/index.js +10 -1
  13. package/runtime/player.js +17 -6
  14. package/studio/index.html +255 -60
  15. package/examples/breath/stills/contact-sheet.jpg +0 -0
  16. package/examples/breath/stills/t000.00.png +0 -0
  17. package/examples/breath/stills/t002.00.png +0 -0
  18. package/examples/breath/stills/t004.00.png +0 -0
  19. package/examples/breath/stills/t006.00.png +0 -0
  20. package/examples/breath/stills/t008.00.png +0 -0
  21. package/examples/breath/stills/t010.00.png +0 -0
  22. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  23. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  24. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  25. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  26. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  27. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  28. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  29. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  30. package/examples/halo-explainer/stills/t000.00.png +0 -0
  31. package/examples/halo-explainer/stills/t006.00.png +0 -0
  32. package/examples/halo-explainer/stills/t012.00.png +0 -0
  33. package/examples/halo-explainer/stills/t018.00.png +0 -0
  34. package/examples/halo-explainer/stills/t024.00.png +0 -0
  35. package/examples/halo-explainer/stills/t030.00.png +0 -0
  36. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  37. package/examples/install-loop/stills/t000.00.png +0 -0
  38. package/examples/install-loop/stills/t001.00.png +0 -0
  39. package/examples/install-loop/stills/t002.00.png +0 -0
  40. package/examples/install-loop/stills/t003.00.png +0 -0
  41. package/examples/install-loop/stills/t004.00.png +0 -0
  42. package/examples/install-loop/stills/t005.00.png +0 -0
  43. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  44. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  45. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  46. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  47. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  48. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  49. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  50. package/examples/signal/stills/contact-sheet.jpg +0 -0
  51. package/examples/signal/stills/t000.00.png +0 -0
  52. package/examples/signal/stills/t001.67.png +0 -0
  53. package/examples/signal/stills/t003.33.png +0 -0
  54. package/examples/signal/stills/t005.00.png +0 -0
  55. package/examples/signal/stills/t006.67.png +0 -0
  56. package/examples/signal/stills/t008.33.png +0 -0
package/lib/piece.mjs CHANGED
@@ -1,12 +1,17 @@
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 → inventory → video → final. The user sees three steps (What it says,
4
- // Your video, Export); the brief is settled in the chat.
3
+ // stage the stage the piece is at: brief → inventory → look → video → final. The user sees four steps (What
4
+ // you want to say: the brief and the inventory; How it looks; Your video; Export).
5
5
  // stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
6
6
  // brief what, who, format, tone, action: written by the agent, editable by the user
7
7
  // parts per part (by its folder in chain.json): status (queued · rendering · review · changes · approved), note
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)
8
+ // events what the user did in the studio, in order; `seen` is the last one the agent has read, and each
9
+ // event the agent read carries `readAt`
10
+ // ask a question the agent asked and is waiting on; the user answers it in the studio (an `answer` event)
11
+ // working what the agent said it is doing (`piece working --on`) and since when; it lapses after 20 minutes
12
+ // notes the agent's short replies to the user, shown in the studio's thread (`piece say`)
13
+ // choices options the agent put into the studio (`pamo board` while a piece is open), where they show, and
14
+ // the reply line once the user chose (ADR 0005)
10
15
  // agent the app or page the agent runs in, so the studio can take the user back to it
11
16
  //
12
17
  // chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
@@ -14,7 +19,7 @@ import fs from 'node:fs';
14
19
  import path from 'node:path';
15
20
  import { pieceDir } from './chain.mjs';
16
21
 
17
- export const STAGES = ['brief', 'inventory', 'video', 'final'];
22
+ export const STAGES = ['brief', 'inventory', 'look', 'video', 'final'];
18
23
  // the stages a piece had before Your video took them over: a piece at one of them is at `video` now
19
24
  const OLD_VIDEO = ['look', 'structure', 'parts', 'assembly'];
20
25
  export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
@@ -39,6 +44,13 @@ function upgrade(p) {
39
44
  return p;
40
45
  }
41
46
 
47
+ /** A project whose brand already settles colours, type and a background: How it looks starts done. */
48
+ export function brandSettled(cwd) {
49
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), 'utf8')); } catch { return {}; } };
50
+ const t = read('brand/tokens.json'), set = read('brand/shader-set/set.json');
51
+ return !!(t.colors?.ground && t.colors?.figure && t.fonts?.display && (Array.isArray(set) ? set : set.entries ?? []).some((e) => e.shader && !e.image));
52
+ }
53
+
42
54
  /** Writes atomically (the studio and the CLI both write it). */
43
55
  export function writePiece(dir, p) {
44
56
  fs.mkdirSync(dir, { recursive: true });
@@ -67,7 +79,7 @@ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force
67
79
  return writePiece(dir, {
68
80
  v: 1, piece: path.basename(dir), title: title ?? path.basename(dir), brand: tokens.brand ?? null,
69
81
  stage: 'brief',
70
- stages: Object.fromEntries(STAGES.map((s, i) => [s, { status: i ? 'pending' : 'working' }])),
82
+ stages: Object.fromEntries(STAGES.map((s, i) => [s, s === 'look' && brandSettled(cwd) ? { status: 'approved', brand: true } : { status: i ? 'pending' : 'working' }])),
71
83
  brief: Object.fromEntries(BRIEF_KEYS.map((k) => [k, brief[k] ?? ''])),
72
84
  parts: {}, events: [], seen: 0, created: now(), updated: now(),
73
85
  });
@@ -75,12 +87,14 @@ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force
75
87
 
76
88
  /** The line the agent reads for an event (same family as the board reply). */
77
89
  export function eventLine(p, e) {
78
- const who = e.part ? `part ${e.part}` : e.stage;
90
+ const who = e.part ? `part ${e.part}` : e.stage ?? '';
79
91
  const what = e.kind === 'approve' ? 'approved'
80
92
  : e.kind === 'changes' ? `changes: "${e.text}"`
81
93
  : e.kind === 'order' ? `order ${e.text}`
82
94
  : e.kind === 'reopen' ? 'reopened'
83
95
  : e.kind === 'failed' ? `could not join the parts: "${e.text}"`
96
+ : e.kind === 'answer' ? `answered${e.q ? ` "${e.q}"` : ''}: "${e.text}"`
97
+ : e.kind === 'choice' ? `chose${e.title ? ` on "${e.title}"` : ''}: ${e.text}`
84
98
  : e.say ? e.say : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
85
99
  // the studio joins the draft again by itself after an edit by hand; the full-quality render stays the agent's
86
100
  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` : ''}`;
@@ -105,6 +119,7 @@ export function toReview(dir, stage, { skip = false } = {}) {
105
119
  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
120
  for (const s of open) p.stages[s] = { ...p.stages[s], status: 'approved' };
107
121
  delete p.ask; // handing a stage over means the question in the chat is settled
122
+ delete p.working;
108
123
  p.stage = stage;
109
124
  p.stages[stage] = { status: 'review', since: now() };
110
125
  });
@@ -116,10 +131,55 @@ export const earlierOpen = (p, stage) => STAGES.slice(0, STAGES.indexOf(stage)).
116
131
  /** Events the agent hasn't read; marks them read when `mark`. */
117
132
  export function unseen(dir, { mark = true } = {}) {
118
133
  let out = [];
119
- const p = updatePiece(dir, (q) => { out = q.events.filter((e) => e.n > (q.seen ?? 0)); if (mark && out.length) q.seen = out.at(-1).n; });
134
+ const p = updatePiece(dir, (q) => {
135
+ out = q.events.filter((e) => e.n > (q.seen ?? 0));
136
+ if (mark && out.length) { const at = now(); for (const e of out) e.readAt = at; q.seen = out.at(-1).n; }
137
+ });
120
138
  return { piece: p, events: out };
121
139
  }
122
140
 
141
+ // what the user does that needs the agent: something asked, answered, approved, reopened, chosen, or a join that failed
142
+ const needsAgent = (e) => ['changes', 'answer', 'reopen', 'choice', 'failed'].includes(e.kind) || (e.kind === 'approve' && !e.part) || e.rejoin || e.refinal;
143
+
144
+ /**
145
+ * Listens on the piece between the agent's turns (`pamo piece listen`): returns as soon as the user does something
146
+ * that needs the agent, with every unread event as lines (and marks them read), or after `timeout` with nothing.
147
+ * While it listens the studio says so; it is the agent's way to hear the studio without the user going to the chat.
148
+ */
149
+ export async function listenFor(dir, { timeout = 9 * 60 * 1000, every = 1000, signal } = {}) {
150
+ const t0 = Date.now();
151
+ let last = 0;
152
+ updatePiece(dir, (p) => { delete p.working; }); // listening means it is not doing anything else
153
+ // interrupted (the agent cancels it, Ctrl+C): stop saying it listens right away
154
+ const stop = () => { unbeat(dir); process.exit(130); };
155
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
156
+ try {
157
+ for (;;) {
158
+ if (Date.now() - last > 3000) { beat(dir, { waiting: 'listen' }); last = Date.now(); }
159
+ const p = readPiece(dir);
160
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
161
+ if (fresh.some(needsAgent)) { const r = unseen(dir); return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) }; }
162
+ if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: [], lines: [] };
163
+ await new Promise((res) => setTimeout(res, every));
164
+ }
165
+ } finally { unbeat(dir); process.off('SIGINT', stop); process.off('SIGTERM', stop); }
166
+ }
167
+
168
+ const WORKING_FOR = 20 * 60 * 1000;
169
+ /** The agent says what it is doing; the studio shows it, with how long, until it listens, reviews or 20 minutes pass. */
170
+ export function setWorking(dir, on) { return updatePiece(dir, (p) => { delete p.ask; p.working = { on: String(on ?? '').trim() || null, at: now() }; }); }
171
+ /** A short reply from the agent to the user, shown in the studio's thread. */
172
+ export function addNote(dir, text) { return updatePiece(dir, (p) => { (p.notes ??= []).push({ at: now(), text: String(text).trim() }); }); }
173
+
174
+ /**
175
+ * What the studio may say about the agent, only from facts: `ask` (a question is open), `listen` (a command of the
176
+ * agent is waiting on this piece right now), `work` (it said what it is doing, less than 20 minutes ago) or `off`.
177
+ */
178
+ export function agentState(dir, p = readPiece(dir)) {
179
+ const l = listening(dir), w = p?.working && Date.now() - Date.parse(p.working.at) < WORKING_FOR ? p.working : null;
180
+ return { state: p?.ask ? 'ask' : l ? 'listen' : w ? 'work' : 'off', listening: !!l, working: w };
181
+ }
182
+
123
183
  // an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
124
184
  const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin || e.refinal;
125
185
 
@@ -152,7 +212,15 @@ const beatFile = (dir) => path.join(dir, '.agent.json');
152
212
  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
213
  export function unbeat(dir) { try { if (JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')).pid === process.pid) fs.rmSync(beatFile(dir)); } catch { /* already gone */ } }
154
214
  /** 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; } }
215
+ export function listening(dir, within = 9000) {
216
+ try {
217
+ const a = JSON.parse(fs.readFileSync(beatFile(dir), 'utf8'));
218
+ if (Date.now() - a.at >= within) return null;
219
+ // a command that was killed leaves its last beat behind: it only counts while its process is alive
220
+ if (a.pid) { try { process.kill(a.pid, 0); } catch (e) { if (e.code === 'ESRCH') return null; } }
221
+ return a;
222
+ } catch { return null; }
223
+ }
156
224
 
157
225
  /** The agent asked the user something in the chat: the studio says so until the agent moves on. */
158
226
  export function setAsk(dir, text) { return updatePiece(dir, (p) => { p.ask = { text: String(text ?? '').trim(), at: now() }; }); }
@@ -196,6 +264,49 @@ export function setSceneText(html, key, value) {
196
264
  return head + next + tail;
197
265
  }
198
266
 
267
+ // ── choices: a board shown inside the studio instead of a link (ADR 0005) ─────────────────────────────────────
268
+ /** Puts a board into the piece, on the step and section it decides. Returns its id (`c1`, `c2`…). */
269
+ export function addChoice(dir, { board, where }) {
270
+ let id;
271
+ updatePiece(dir, (p) => {
272
+ p.choices ??= [];
273
+ id = `c${p.choices.length + 1}`;
274
+ p.choices.push({ id, board, where, status: 'waiting', at: now() });
275
+ });
276
+ return id;
277
+ }
278
+ /** The user's pick on a choice: records the reply line and an event the agent reads. */
279
+ export function settleChoice(p, id, line) {
280
+ const c = (p.choices ?? []).find((x) => x.id === id);
281
+ if (!c) throw new Error('no such choice');
282
+ if (c.status !== 'waiting') throw new Error('already chosen');
283
+ c.status = 'chosen'; c.line = line; c.chosenAt = now();
284
+ addEvent(p, { kind: 'choice', stage: c.where?.step ?? p.stage, text: line, title: c.board.title, choice: id });
285
+ return c;
286
+ }
287
+ /** Waits for the user's pick on a studio choice, beating as listening; returns the reply line, or null on timeout. */
288
+ export async function awaitChoiceIn(dir, id, { timeout = 10 * 60 * 1000, every = 1000 } = {}) {
289
+ const t0 = Date.now(); let last = 0;
290
+ const stop = () => { unbeat(dir); process.exit(130); };
291
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
292
+ try {
293
+ for (;;) {
294
+ if (Date.now() - last > 3000) { beat(dir, { waiting: 'board' }); last = Date.now(); }
295
+ const c = (readPiece(dir)?.choices ?? []).find((x) => x.id === id);
296
+ if (!c) return null;
297
+ if (c.status === 'chosen') {
298
+ // the agent has the pick from here: the event that carries it counts as read
299
+ updatePiece(dir, (p) => { const at = now(); for (const e of p.events) if (e.kind === 'choice' && e.choice === id && !e.readAt) { e.readAt = at; if (e.n === (p.seen ?? 0) + 1) p.seen = e.n; } });
300
+ return c.line;
301
+ }
302
+ if (Date.now() - t0 > timeout) return null;
303
+ await new Promise((res) => setTimeout(res, every));
304
+ }
305
+ } finally { unbeat(dir); process.off('SIGINT', stop); process.off('SIGTERM', stop); }
306
+ }
307
+ /** Takes a choice back out of the studio. */
308
+ export function forgetChoice(dir, id) { return updatePiece(dir, (p) => { p.choices = (p.choices ?? []).filter((x) => x.id !== id); }); }
309
+
199
310
  // ── boards: the decisions taken on paper-motion.app, listed in the studio so the user finds them there too ──────
200
311
  const BOARD_STAGE = () => 'video';
201
312
 
@@ -204,7 +315,7 @@ export function activePiece(cwd, name) {
204
315
  if (name) { const dir = pieceDir(name, cwd); return readPiece(dir) ? dir : null; }
205
316
  const root = path.join(cwd, 'pieces');
206
317
  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); });
318
+ const live = fs.readdirSync(root).map((d) => path.join(root, d)).filter((d) => { const p = readPiece(d); return p && ['brief', 'inventory', 'look', 'video'].includes(p.stage); });
208
319
  return live.length === 1 ? live[0] : null;
209
320
  }
210
321
 
package/lib/registry.mjs CHANGED
@@ -122,7 +122,11 @@ export function llmsTxt(items, site, { full = false } = {}) {
122
122
  (an approval, a change to make, or an edit they already made by hand: the brief, words on screen, lengths, joins,
123
123
  order; build on those, never undo them). Quick renders for review: \`chain <piece> --drafts\` (each part on its
124
124
  own) and \`chain <piece> --draft\` (joined). Only when the user can't open a browser, run the same stages in the
125
- chat.
125
+ chat. Between your turns keep \`npx paper-motion piece listen <piece>\` running in the background: it returns as
126
+ soon as the user needs you (a message, an answer, an approval), with lines like review's, so they never have to
127
+ come back to the chat (start it again after; exit 3 means nothing in 9 minutes). Say what you are doing with
128
+ \`piece working <piece> <stage> --on "…"\`, ask with \`piece ask <piece> "…"\` (they answer in the studio), and
129
+ reply there with \`piece say <piece> "…"\`. The studio only shows what it can check about you.
126
130
  3. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
127
131
  line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
128
132
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
@@ -164,9 +168,11 @@ export function llmsTxt(items, site, { full = false } = {}) {
164
168
  builds the video itself from what they kept, and then they edit it there, live (words, how each part arrives,
165
169
  backgrounds, settings, lengths, transitions, order, adding and swapping parts, colours, type, pace); every edit
166
170
  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).
171
+ length. While a piece is open and its studio runs, \`board --url\` puts the options into the studio, on the step
172
+ they decide (How it looks for palettes, type, backgrounds and moves; Your video for transitions and structures),
173
+ instead of making a link; it waits there and prints the same reply line, and a palette, font pair or background
174
+ the user picks is applied already. \`--link\` makes a link anyway. If your shell caps how long a command may run,
175
+ use \`--no-wait\` and then \`npx paper-motion board --await <c1…> --wait 2\` (exit code 3 = not yet).
170
176
 
171
177
  ## Choosing well: the metadata
172
178
 
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();
package/lib/studio.mjs CHANGED
@@ -18,10 +18,13 @@
18
18
  // joined draft or the final were made from something that has changed since (`media.assemblyStale`, `finalStale`).
19
19
  import fs from 'node:fs';
20
20
  import http from 'node:http';
21
+ import os from 'node:os';
21
22
  import path from 'node:path';
22
23
  import { fileURLToPath } from 'node:url';
23
24
  import { pieceDir, JOINS, chainSig, readSigs, readJob } from './chain.mjs';
24
- import { STAGES, BRIEF_KEYS, readPiece, updatePiece, addEvent, eventLine, sceneTexts, setSceneText, listening } from './piece.mjs';
25
+ import { STAGES, BRIEF_KEYS, readPiece, updatePiece, addEvent, eventLine, sceneTexts, setSceneText, listening, agentState, settleChoice } from './piece.mjs';
26
+ import { replyLine } from './live-board.js';
27
+ import { setArgs } from './customize.mjs';
25
28
  import { videoView, editVideo, styleView, editStyle, undo, canUndo } from './edit.mjs';
26
29
  import { KINDS, EDITABLE, ADDABLE, MULTI, PLAIN, GROUPS, listText, loadRaw, normalize, candidates, scenesOf, describe, applyEdit, evalFill, fillConfig } from './inventory.mjs';
27
30
 
@@ -63,6 +66,9 @@ const joinErrors = new Map(); // piece folder → why the studio's last join f
63
66
  // the blocks and shots that show content and the library's backgrounds, read once when the studio starts (the
64
67
  // registry may be the site); LIVE is the scene server the page's live previews play from
65
68
  let SCENES = [], BACKGROUNDS = [], TITLES = new Map(), PALETTES = [], FONTPAIRS = [], LIVE = null;
69
+ // How it looks: the library's palettes (every colour by role), font pairs (families and their stylesheets) and
70
+ // background resources (to recolour them into a palette), read with the index
71
+ let LOOK = { palettes: [], fonts: [], bgItems: new Map(), entrances: [], items: new Map() };
66
72
  const building = new Set(), rendering = new Map(); // pieces whose first version or final is being made by the studio
67
73
  // a resource's sample (clip and still): the repo's own when the studio runs inside it (new blocks show before the
68
74
  // site has them), else the site's
@@ -151,6 +157,129 @@ function libraryView() {
151
157
  };
152
158
  }
153
159
 
160
+ // ── scene stills: one small frame of each scene as it really is (the user's words, brand and settings), for the
161
+ // list in Your video. Rendered one at a time, only when a scene changed, never while a final renders. Until a
162
+ // scene has one, the list shows the library's still of its block.
163
+ const stillJobs = [];
164
+ let stillBusy = false;
165
+ function sceneStill(cwd, dir, x, stamp) {
166
+ const d = path.join(dir, '.stills'), img = path.join(d, `${x.dir}.png`), meta = path.join(d, `${x.dir}.json`), key = `${x.mtime}-${stamp}`;
167
+ const have = fs.existsSync(img), was = readJson(meta, {}).key;
168
+ if (was !== key && LIVE && !stillJobs.some((j) => j.img === img)) {
169
+ stillJobs.push({ dir, img, meta, key, url: `${LIVE}/${path.relative(cwd, path.join(dir, x.dir, 'scene.html')).split(path.sep).map(encodeURIComponent).join('/')}?render`, t: (Number(x.duration) || 4) * 0.62 });
170
+ setTimeout(runStills, 400);
171
+ }
172
+ return have ? fileUrl(cwd, img) : null;
173
+ }
174
+ async function runStills() {
175
+ if (stillBusy) return;
176
+ stillBusy = true;
177
+ try {
178
+ const { renderStills } = await import('./render.mjs');
179
+ while (stillJobs.length) {
180
+ const j = stillJobs[0];
181
+ if (rendering.has(j.dir) || building.has(j.dir)) { await new Promise((r) => setTimeout(r, 3000)); continue; }
182
+ stillJobs.shift();
183
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-still-'));
184
+ try {
185
+ const r = await renderStills({ url: j.url, times: [j.t], outDir: out, dpr: 0.35, sheet: false });
186
+ fs.mkdirSync(path.dirname(j.img), { recursive: true });
187
+ fs.copyFileSync(r.files[0], j.img);
188
+ fs.writeFileSync(j.meta, JSON.stringify({ key: j.key }));
189
+ } catch { /* the list keeps the library's still */ } finally { fs.rmSync(out, { recursive: true, force: true }); }
190
+ }
191
+ } finally { stillBusy = false; }
192
+ }
193
+
194
+ // ── How it looks ────────────────────────────────────────────────────────────────────────────────────────────
195
+ const b64url = (o) => Buffer.from(JSON.stringify(o), 'utf8').toString('base64url');
196
+ const hexOf = (v) => (typeof v === 'string' ? v : v?.hex ?? null);
197
+ /** The sentence the look is judged with: the first one the user must show, else the first they kept, else the brief. */
198
+ function headline(cwd, dir) {
199
+ try { const items = normalize(loadRaw(dir), { cwd }).items.filter((i) => i.kind === 'line'); return (items.find((i) => i.must) ?? items[0])?.text ?? readPiece(dir)?.brief?.what ?? ''; } catch { return readPiece(dir)?.brief?.what ?? ''; }
200
+ }
201
+ /** The brand's look as it is, and every option How it looks offers, as the page shows them (stills, no shaders). */
202
+ function lookView(cwd, dir) {
203
+ const t = readJson(path.join(cwd, 'brand/tokens.json'), {}), m = readJson(path.join(cwd, 'brand/motion.json'), {});
204
+ const set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {}), entries = (set.entries ?? []).filter((e) => e.shader && !e.image);
205
+ const colors = { ground: hexOf(t.colors?.ground), figure: hexOf(t.colors?.figure), accent: hexOf(t.colors?.accent) };
206
+ const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase();
207
+ const palette = LOOK.palettes.find((x) => ['ground', 'figure', 'accent'].every((k) => same(x.colors[k], colors[k])))?.id ?? (colors.ground ? 'custom' : null);
208
+ const font = LOOK.fonts.find((f) => same(f.fonts.display.family, t.fonts?.display?.family) && same(f.fonts.text?.family, t.fonts?.text?.family))?.id ?? null;
209
+ const mine = new Set(entries.map((e) => e.id));
210
+ return {
211
+ current: { colors, palette, font, display: t.fonts?.display?.family ?? null, text: t.fonts?.text?.family ?? null, background: entries[0]?.id ?? null, pace: m.pace ?? 'medium', entrance: m.entrance ?? null },
212
+ headline: headline(cwd, dir), live: !!LIVE,
213
+ palettes: LOOK.palettes.map((x) => ({ id: x.id, title: x.title, colors: x.colors })),
214
+ fonts: LOOK.fonts.map((f) => ({ id: f.id, title: f.title, display: f.fonts.display.family, text: f.fonts.text?.family ?? null, css: [f.fonts.display.css, f.fonts.text?.css].filter(Boolean) })),
215
+ backgrounds: [
216
+ ...entries.map((e) => ({ id: e.id, title: TITLES.get(e.id) ?? e.id, mine: true, poster: fs.existsSync(path.join(LOCAL_PREVIEWS, `${e.id}.jpg`)) || TITLES.has(e.id) ? preview(e.id, 'jpg') : null, colors: (e.props?.colors ?? [e.props?.colorBack, e.props?.colorFront]).filter(Boolean).slice(0, 4) })),
217
+ ...BACKGROUNDS.filter((b) => !mine.has(b.id)).map((b) => ({ id: b.id, title: b.title, mine: false, poster: preview(b.id, 'jpg'), colors: b.colors ?? [] })),
218
+ ],
219
+ entrances: LOOK.entrances, paces: ['calm', 'medium', 'fast'],
220
+ };
221
+ }
222
+ /**
223
+ * The look preview: the user's sentence in `line-in`, playing live from .try/look/, with a look laid over the brand
224
+ * for this preview only (`?embed&look=…`, read by runtime/brand.js). `o` changes any of palette, colors, font,
225
+ * background, pace, entrance; what it leaves out is the brand's own. One preview plays at a time.
226
+ */
227
+ async function lookPreview(cwd, piece, o = {}) {
228
+ if (!LIVE) throw new Error('the live preview is not ready yet');
229
+ const dir = pieceDir(piece, cwd), sub = path.join('.try', 'look'), file = path.join(dir, sub, 'scene.html');
230
+ if (!fs.existsSync(file)) { const { add } = await import('./registry.mjs'); await add(['line-in'], { cwd, force: true, name: path.join(path.relative(path.join(cwd, 'pieces'), dir), sub) }); }
231
+ const text = headline(cwd, dir), html = fs.readFileSync(file, 'utf8'), want = fillConfig(html, { text, mark: '', entrance: null, background: null });
232
+ if (want !== html) fs.writeFileSync(file, want);
233
+ const t = readJson(path.join(cwd, 'brand/tokens.json'), {}), set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {});
234
+ const pal = o.palette ? LOOK.palettes.find((x) => x.id === o.palette)?.colors : null;
235
+ const colors = { ...Object.fromEntries(Object.entries(t.colors ?? {}).map(([k, v]) => [k, hexOf(v)])), ...(pal ?? {}), ...(o.colors ?? {}) };
236
+ const look = {};
237
+ if (pal || o.colors) look.colors = colors;
238
+ if (o.font) { const f = LOOK.fonts.find((x) => x.id === o.font); if (f) look.fonts = f.fonts; }
239
+ // the background: the one asked for, else the brand's first; recoloured whenever the colours are not the brand's own
240
+ const entries = (set.entries ?? []).filter((e) => e.shader && !e.image), bgId = o.background ?? entries[0]?.id;
241
+ const item = LOOK.bgItems.get(bgId), own = entries.find((e) => e.id === bgId);
242
+ if (item && (o.background || look.colors)) {
243
+ const { recolor } = await import('./customize.mjs');
244
+ const e = item.install?.entry ?? {};
245
+ look.background = { ...e, id: bgId, props: { ...(e.props ?? {}), ...recolor(item, colors) } };
246
+ } else if (own && o.background) look.background = own;
247
+ if (o.pace) look.pace = o.pace;
248
+ if (o.entrance) look.entrance = o.entrance;
249
+ return { url: `${LIVE}/${path.relative(cwd, file).split(path.sep).map(encodeURIComponent).join('/')}?embed&look=${b64url(look)}&v=${Date.now()}` };
250
+ }
251
+
252
+ // ── choices from the agent (ADR 0005): what each option's card needs, and what the studio applies on a pick ──────
253
+ const COLOR_ROLES = ['ground', 'figure', 'muted', 'surface', 'accent'];
254
+ function itemFace(o) {
255
+ const it = LOOK.items.get(o.id), type = it?.type ?? null, face = { id: o.id, type, title: `${it?.title ?? o.id}${Object.keys(o.values ?? {}).length ? ', tuned' : ''}` };
256
+ if (type === 'palette') face.colors = { ...(LOOK.palettes.find((x) => x.id === o.id)?.colors ?? {}), ...Object.fromEntries(Object.entries(o.values ?? {}).filter(([k]) => COLOR_ROLES.includes(k))) };
257
+ else if (type === 'font-pair') { const f = LOOK.fonts.find((x) => x.id === o.id); if (f) Object.assign(face, { display: f.fonts.display.family, text: f.fonts.text?.family ?? null, css: [f.fonts.display.css, f.fonts.text?.css].filter(Boolean) }); }
258
+ else Object.assign(face, { poster: preview(o.id, 'jpg'), clip: type === 'background' ? null : preview(o.id, 'webm') });
259
+ return face;
260
+ }
261
+ function choiceView(c) {
262
+ const opt = (o) => {
263
+ const base = { label: o.label, note: o.note ?? null };
264
+ if (o.structure) return { ...base, title: o.structure.title, seconds: o.structure.duration, beats: o.structure.beats.map((b) => ({ name: b.name, what: b.what, id: b.id ?? null, from: b.from, to: b.to })) };
265
+ if (c.board.kind === 'kit') return { ...base, title: `Look ${o.label}`, items: o.items.map(itemFace) };
266
+ return { ...base, ...itemFace(o) };
267
+ };
268
+ return { id: c.id, title: c.board.title, ask: c.board.ask ?? null, where: c.where, status: c.status, line: c.line ?? null, options: c.board.options.map(opt) };
269
+ }
270
+ /** After a pick: what the studio itself applies to brand/ (palette, font pair, background); the rest is the agent's. */
271
+ async function applyChoice(cwd, dir, c, label) {
272
+ const o = c.board.options.find((x) => x.label === label); if (!o) return;
273
+ for (const x of c.board.kind === 'kit' ? o.items : [o]) {
274
+ const type = LOOK.items.get(x.id)?.type;
275
+ if (type === 'palette') {
276
+ await editStyle(cwd, dir, { type: 'palette', value: x.id });
277
+ for (const k of ['ground', 'figure', 'accent']) if (x.values?.[k]) await editStyle(cwd, dir, { type: 'color', key: k, value: x.values[k] });
278
+ } else if (type === 'font-pair') await editStyle(cwd, dir, { type: 'font', value: x.id });
279
+ else if (type === 'background') await editStyle(cwd, dir, { type: 'background', value: x.id });
280
+ }
281
+ }
282
+
154
283
  /** What the page shows for one piece. */
155
284
  export function view(cwd, piece) {
156
285
  const dir = pieceDir(piece, cwd), p = readPiece(dir);
@@ -177,13 +306,16 @@ export function view(cwd, piece) {
177
306
  video: (() => {
178
307
  const v = videoView(cwd, dir, SCENES); if (!v) return null;
179
308
  const rel = path.relative(cwd, dir).split(path.sep).map(encodeURIComponent).join('/');
180
- for (const x of v.parts) { x.live = LIVE ? `${LIVE}/${rel}/${encodeURIComponent(x.dir)}/scene.html?embed&v=${x.mtime}-${styleView(cwd).stamp}` : null; x.poster = preview(x.id, 'jpg'); x.clip = preview(x.id, 'webm');
309
+ const stamp = styleView(cwd).stamp;
310
+ for (const x of v.parts) { x.still = sceneStill(cwd, dir, x, stamp); x.live = LIVE ? `${LIVE}/${rel}/${encodeURIComponent(x.dir)}/scene.html?embed&rate=${x.rate}&v=${x.mtime}-${styleView(cwd).stamp}` : null; x.poster = preview(x.id, 'jpg'); x.clip = preview(x.id, 'webm');
181
311
  if (x.motion) x.motion.demos = x.motion.options.map((o) => ({ value: o, title: TITLES.get(x.motion.previews[o]) ?? o, poster: x.motion.previews[o] ? preview(x.motion.previews[o], 'jpg') : null, clip: x.motion.previews[o] ? preview(x.motion.previews[o], 'webm') : null })); }
182
312
  return v;
183
313
  })(),
184
314
  building: building.has(dir), rendering: rendering.get(dir) ?? null, undo: canUndo(dir),
185
315
  library: libraryView(),
186
316
  style: { ...styleView(cwd), palettes: PALETTES, fontpairs: FONTPAIRS },
317
+ lookOptions: lookView(cwd, dir), // How it looks: the brand's look now and every option, as stills
318
+ choices: (p.choices ?? []).map(choiceView), // what the agent put into the studio for the user to decide
187
319
  look: {
188
320
  colors: Object.entries(tokens.colors ?? {}).map(([k, v]) => [k, v?.hex ?? v]),
189
321
  fonts: Object.entries(tokens.fonts ?? {}).map(([k, v]) => [k, v?.family ?? v, v?.css ?? null]),
@@ -195,7 +327,13 @@ export function view(cwd, piece) {
195
327
  job: film.job,
196
328
  ask: p.ask ?? null,
197
329
  // is a command of the agent waiting on this piece, how many of the user's moves it hasn't read, and the way back to it
198
- agent: { listening: !!listening(dir), app: p.agent?.app ?? null, focus: process.platform === 'darwin' && !!p.agent?.path, url: p.agent?.url ?? null },
330
+ // only what can be checked: a question open, a command waiting on the piece now, or what it said it is doing (20 min)
331
+ agent: { ...agentState(dir, p), app: p.agent?.app ?? null, focus: process.platform === 'darwin' && !!p.agent?.path, url: p.agent?.url ?? null },
332
+ // the conversation in the studio: what the user sent (and when the agent read it) and the agent's replies
333
+ thread: [
334
+ ...p.events.filter((e) => (e.kind === 'changes' && !e.part) || e.kind === 'answer').map((e) => ({ who: 'you', text: e.kind === 'answer' ? `${e.q ? `“${e.q}” ` : ''}${e.text}` : e.text, at: e.at, read: e.readAt ?? (e.n <= (p.seen ?? 0) ? e.at : null) })),
335
+ ...(p.notes ?? []).map((x) => ({ who: 'agent', text: x.text, at: x.at })),
336
+ ].sort((x, y) => x.at.localeCompare(y.at)),
199
337
  unread: p.events.filter((e) => e.n > (p.seen ?? 0)).length,
200
338
  };
201
339
  }
@@ -243,7 +381,9 @@ function act(cwd, piece, a, queue) {
243
381
  }
244
382
  p.stages[a.stage] = { status: 'approved', at: new Date().toISOString() };
245
383
  const next = STAGES[STAGES.indexOf(a.stage) + 1];
246
- if (next) { p.stage = next; if (p.stages[next].status === 'pending') p.stages[next] = { status: 'working', since: new Date().toISOString() }; }
384
+ // How it looks is the user's step: it opens ready to choose (or done, when the brand already settles it)
385
+ if (next === 'look') { if (STAGES.indexOf(p.stage) < STAGES.indexOf('look')) p.stage = next; if (p.stages.look.status !== 'approved') p.stages.look = { status: 'review', since: new Date().toISOString() }; }
386
+ else if (next && !(a.stage === 'look' && STAGES.indexOf(p.stage) > STAGES.indexOf('look'))) { p.stage = next; if (p.stages[next].status === 'pending') p.stages[next] = { status: 'working', since: new Date().toISOString() }; }
247
387
  addEvent(p, { kind: 'approve', stage: a.stage });
248
388
  break;
249
389
  }
@@ -254,6 +394,30 @@ function act(cwd, piece, a, queue) {
254
394
  break;
255
395
  }
256
396
  case 'reopen': { p.stages[a.stage] = { status: 'review' }; addEvent(p, { kind: 'reopen', stage: a.stage }); break; }
397
+ case 'message': { // Ask your agent: a request, read as a changes line, that leaves the stage where it is
398
+ const t = text(a.text); if (!t) throw new Error('say what you need');
399
+ addEvent(p, { kind: 'changes', stage: p.stage, text: t, msg: true });
400
+ break;
401
+ }
402
+ case 'answer': { // the agent's open question, answered here
403
+ const t = text(a.text); if (!t) throw new Error('write your answer');
404
+ addEvent(p, { kind: 'answer', text: t, q: p.ask?.text ?? null });
405
+ delete p.ask;
406
+ break;
407
+ }
408
+ case 'ask-close': { delete p.ask; break; } // answered in the chat instead
409
+ case 'choose': { // a pick on a choice the agent put here, or none of them with what they'd rather see
410
+ const c = (p.choices ?? []).find((x) => x.id === a.choice); if (!c) throw new Error('no such choice');
411
+ let line;
412
+ if (a.none) line = replyLine(c.board, { none: true, text: text(a.text) });
413
+ else {
414
+ const o = c.board.options.find((x) => x.label === a.label); if (!o) throw new Error('no such option');
415
+ const it = o.id ? LOOK.items.get(o.id) : null;
416
+ line = replyLine(c.board, { label: o.label, setArgs: it && o.values && Object.keys(o.values).length ? setArgs(it, o.values) : [] });
417
+ }
418
+ settleChoice(p, c.id, line);
419
+ break;
420
+ }
257
421
  case 'approve-part': { p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'approved', note: undefined }; addEvent(p, { kind: 'approve', stage: 'parts', part: a.part }); break; }
258
422
  case 'brief': {
259
423
  if (!BRIEF_KEYS.includes(a.key)) throw new Error('bad brief key');
@@ -444,10 +608,28 @@ function sendFile(req, res, f) {
444
608
 
445
609
  export function startStudio({ cwd = process.cwd(), port = 4317, log = console.log } = {}) {
446
610
  const queue = renderQueue(cwd, log);
447
- import('./registry.mjs').then((r) => r.getIndex()).then((i) => { // offline: items show without their blocks
611
+ import('./registry.mjs').then(async (r) => {
612
+ const i = await r.getIndex();
613
+ // the site's index is light (params by name only, no install data): the studio needs the full resource of every
614
+ // palette, font pair and background (How it looks) and of every block and shot (their settings and entrances),
615
+ // fetched a few at a time when the index lacks it (a local registry already has it)
616
+ const light = (i.items ?? []).filter((x) => ['palette', 'font-pair', 'background', 'block', 'shot'].includes(x.type) && (!x.install || (x.params ?? []).some((q) => typeof q === 'string')));
617
+ for (let k = 0; k < light.length; k += 8) {
618
+ const full = await Promise.all(light.slice(k, k + 8).map((x) => r.getItem(x.id).catch(() => null)));
619
+ for (const f of full) if (f) { const at = i.items.findIndex((x) => x.id === f.id); if (at >= 0) i.items[at] = { ...i.items[at], ...f }; }
620
+ }
621
+ return i;
622
+ }).then((i) => { // offline: items show without their blocks
448
623
  SCENES = scenesOf(i); TITLES = new Map((i.items ?? []).map((x) => [x.id, x.title]));
449
624
  PALETTES = (i.items ?? []).filter((x) => x.type === 'palette').map((x) => ({ id: x.id, title: x.title, colors: x.colors ?? [] }));
450
625
  FONTPAIRS = (i.items ?? []).filter((x) => x.type === 'font-pair').map((x) => ({ id: x.id, title: x.title }));
626
+ const def = (x, k) => x.params?.find((q) => q.name === k)?.default;
627
+ LOOK.palettes = (i.items ?? []).filter((x) => x.type === 'palette').map((x) => ({ id: x.id, title: x.title, colors: Object.fromEntries(['ground', 'figure', 'muted', 'surface', 'accent'].map((k) => [k, def(x, k)]).filter(([, v]) => v)) }));
628
+ LOOK.fonts = (i.items ?? []).filter((x) => x.type === 'font-pair' && x.install?.entry?.fonts?.display).map((x) => ({ id: x.id, title: x.title, fonts: x.install.entry.fonts, type: x.install.entry.type ?? null }));
629
+ LOOK.bgItems = new Map((i.items ?? []).filter((x) => x.type === 'background').map((x) => [x.id, x]));
630
+ LOOK.items = new Map((i.items ?? []).map((x) => [x.id, x]));
631
+ const li = (i.items ?? []).find((x) => x.id === 'line-in');
632
+ LOOK.entrances = (li?.params?.find((q) => q.name === 'entrance')?.options ?? []).map((o) => { const r = li.repeat?.previews?.[o]; return { value: o, title: TITLES.get(r) ?? o, poster: r ? preview(r, 'jpg') : null, clip: r ? preview(r, 'webm') : null }; });
451
633
  BACKGROUNDS = (i.items ?? []).filter((x) => x.type === 'background' && x.install?.entry?.shader && !x.install.entry.image).map((x) => ({ id: x.id, title: x.title, colors: x.colors ?? [], entry: { shader: x.install.entry.shader, props: x.install.entry.props, background: x.install.entry.background } }));
452
634
  }).catch(() => {});
453
635
  // the page's live previews (a block with the user's content, a background) play from a scene server on this project
@@ -473,8 +655,10 @@ export function startStudio({ cwd = process.cwd(), port = 4317, log = console.lo
473
655
  const a = JSON.parse(body || '{}');
474
656
  if (/^(v-|style-)|^undo$|^render$/.test(a.type)) return json(res, 200, await actVideo(cwd, piece, a, log));
475
657
  const r = act(cwd, piece, a, queue);
476
- // keeping the content builds the first version of the video, by itself: the user lands on something to edit
477
- if (a.type === 'approve' && a.stage === 'inventory') buildFirst(cwd, piece, log);
658
+ // a pick on the agent's palette, font pair or background is applied at once, like any card here
659
+ if (a.type === 'choose' && !a.none) { const dir = pieceDir(piece, cwd), c = (readPiece(dir).choices ?? []).find((x) => x.id === a.choice); if (c) await applyChoice(cwd, dir, c, a.label); }
660
+ // choosing how it looks builds the first version of the video, by itself: the user lands on something to edit
661
+ if (a.type === 'approve' && a.stage === 'look') buildFirst(cwd, piece, log);
478
662
  return json(res, 200, r);
479
663
  } catch (e) { return json(res, 400, { error: e.message.split('\n')[0] }); }
480
664
  }
@@ -487,6 +671,10 @@ export function startStudio({ cwd = process.cwd(), port = 4317, log = console.lo
487
671
  if (!fs.existsSync(f)) { res.writeHead(404); return res.end(); }
488
672
  return sendFile(req, res, f);
489
673
  }
674
+ if (u.pathname === '/api/look' && req.method === 'POST') { // How it looks: the user's sentence with a look laid over
675
+ let body = ''; for await (const c of req) body += c;
676
+ try { return json(res, 200, await lookPreview(cwd, u.searchParams.get('p'), JSON.parse(body || '{}'))); } catch (e) { return json(res, 400, { error: e.message.split('\n')[0] }); }
677
+ }
490
678
  if (u.pathname === '/api/try' && req.method === 'POST') { // a block playing live with one item's content
491
679
  let body = ''; for await (const c of req) body += c;
492
680
  try { const a = JSON.parse(body || '{}'); return json(res, 200, await tryScene(cwd, u.searchParams.get('p'), a.id, a.block)); } catch (e) { return json(res, 400, { error: e.message.split('\n')[0] }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Frame-exact video renderer for HTML scenes built with Paper Shaders: the engine behind the paper-motion agent skills.",
5
5
  "keywords": [
6
6
  "video",
@@ -36,6 +36,7 @@
36
36
  "templates/",
37
37
  "examples/",
38
38
  "!examples/**/renders/",
39
+ "!examples/**/stills/",
39
40
  "README.md",
40
41
  "CHANGELOG.md"
41
42
  ],