paper-motion 0.4.1 → 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 (52) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/bin/pamo.mjs +62 -5
  3. package/lib/edit.mjs +31 -3
  4. package/lib/piece.mjs +121 -10
  5. package/lib/registry.mjs +10 -4
  6. package/lib/studio.mjs +195 -7
  7. package/package.json +2 -1
  8. package/runtime/brand.js +14 -2
  9. package/runtime/player.js +4 -2
  10. package/studio/index.html +236 -57
  11. package/examples/breath/stills/contact-sheet.jpg +0 -0
  12. package/examples/breath/stills/t000.00.png +0 -0
  13. package/examples/breath/stills/t002.00.png +0 -0
  14. package/examples/breath/stills/t004.00.png +0 -0
  15. package/examples/breath/stills/t006.00.png +0 -0
  16. package/examples/breath/stills/t008.00.png +0 -0
  17. package/examples/breath/stills/t010.00.png +0 -0
  18. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  19. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  20. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  21. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  22. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  23. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  24. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  25. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  26. package/examples/halo-explainer/stills/t000.00.png +0 -0
  27. package/examples/halo-explainer/stills/t006.00.png +0 -0
  28. package/examples/halo-explainer/stills/t012.00.png +0 -0
  29. package/examples/halo-explainer/stills/t018.00.png +0 -0
  30. package/examples/halo-explainer/stills/t024.00.png +0 -0
  31. package/examples/halo-explainer/stills/t030.00.png +0 -0
  32. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  33. package/examples/install-loop/stills/t000.00.png +0 -0
  34. package/examples/install-loop/stills/t001.00.png +0 -0
  35. package/examples/install-loop/stills/t002.00.png +0 -0
  36. package/examples/install-loop/stills/t003.00.png +0 -0
  37. package/examples/install-loop/stills/t004.00.png +0 -0
  38. package/examples/install-loop/stills/t005.00.png +0 -0
  39. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  40. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  41. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  42. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  43. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  44. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  45. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  46. package/examples/signal/stills/contact-sheet.jpg +0 -0
  47. package/examples/signal/stills/t000.00.png +0 -0
  48. package/examples/signal/stills/t001.67.png +0 -0
  49. package/examples/signal/stills/t003.33.png +0 -0
  50. package/examples/signal/stills/t005.00.png +0 -0
  51. package/examples/signal/stills/t006.67.png +0 -0
  52. package/examples/signal/stills/t008.33.png +0 -0
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&rate=${x.rate}&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.1",
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
  ],
package/runtime/brand.js CHANGED
@@ -11,11 +11,23 @@ const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text(
11
11
  const sections = (md) => Object.fromEntries((md ?? '').split(/^## /m).slice(1).map((b) => { const i = b.indexOf('\n'); return [b.slice(0, i).trim(), b.slice(i + 1).trim()]; }));
12
12
 
13
13
  export async function loadBrand(root = '/brand') {
14
- const [tokens, identityMd, motion, sound, setRaw] = await Promise.all([
14
+ let [tokens, identityMd, motion, sound, setRaw] = await Promise.all([
15
15
  get(`${root}/tokens.json`, true), get(`${root}/IDENTITY.md`), get(`${root}/motion.json`, true), get(`${root}/sound.json`, true), get(`${root}/shader-set/set.json`, true),
16
16
  ]);
17
17
  const identity = sections(identityMd);
18
- const set = Array.isArray(setRaw) ? { entries: setRaw } : setRaw ?? { entries: [] };
18
+ let set = Array.isArray(setRaw) ? { entries: setRaw } : setRaw ?? { entries: [] };
19
+ // the studio's How it looks preview lays a look over the brand: `?embed&look=<base64url json>` (colours by role,
20
+ // fonts, a background entry, pace, entrance). Only an embedded preview reads it; a render never passes ?embed.
21
+ const q = new URLSearchParams(typeof location === 'undefined' ? '' : location.search);
22
+ let look = null;
23
+ if (q.has('embed') && q.get('look')) { try { look = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(q.get('look').replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)))); } catch { look = null; } }
24
+ if (look) {
25
+ tokens = { ...(tokens ?? {}) };
26
+ if (look.colors) { tokens.colors = { ...(tokens.colors ?? {}), ...Object.fromEntries(Object.entries(look.colors).filter(([, v]) => v).map(([k, v]) => [k, { hex: v }])) }; delete tokens.ramp; }
27
+ if (look.fonts) tokens.fonts = look.fonts;
28
+ if (look.background) set = { ...set, entries: [look.background, ...(set.entries ?? []).filter((e) => e.id !== look.background.id)] };
29
+ if (look.pace || look.entrance) motion = { ...(motion ?? {}), ...(look.pace ? { pace: look.pace } : {}), ...(look.entrance ? { entrance: look.entrance } : {}) };
30
+ }
19
31
 
20
32
  // fonts: stylesheets and files, all loaded before the first frame
21
33
  const fonts = Object.values(tokens?.fonts ?? {}).filter(Boolean);
package/runtime/player.js CHANGED
@@ -46,7 +46,8 @@ export function mountPlayer(scene, startT = 0) {
46
46
  if (!embed) { try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ } }
47
47
  busy = true; await scene.seek(t); busy = false;
48
48
  }
49
- function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); }
49
+ let looping = false; // the frame loop runs only while something plays (an embedded preview that ended is idle)
50
+ function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); if (p && !looping) { looping = true; requestAnimationFrame(tick); } }
50
51
 
51
52
  // embedded, it draws at most 30 frames a second: a preview beside an editor, not the render
52
53
  const step = embed ? 1000 / 30 - 2 : 0;
@@ -60,9 +61,10 @@ export function mountPlayer(scene, startT = 0) {
60
61
  show(nt);
61
62
  }
62
63
  last = now;
64
+ if (embed && !playing) { looping = false; return; }
63
65
  requestAnimationFrame(tick);
64
66
  }
65
- requestAnimationFrame(tick);
67
+ looping = true; requestAnimationFrame(tick);
66
68
 
67
69
  playBtn.onclick = () => setPlaying(!playing);
68
70
  if (embed) addEventListener('click', () => { if (!playing) show(0); setPlaying(true); });