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/studio.mjs CHANGED
@@ -5,22 +5,30 @@
5
5
  // GET /api/pieces pieces in this project that have a piece.json
6
6
  // GET /api/piece?p=<piece> everything the page shows: piece.json + chain + look + media
7
7
  // POST /api/piece?p=<piece> an action: { type, … } (see act())
8
+ // POST /api/focus?p=<piece> brings the agent's app to the front (the app the CLI found itself running in)
8
9
  // GET /files/<path> files under the project (renders, drafts), with byte ranges for video
9
10
  //
10
11
  // Edits that need no judgment are applied here: a text goes into the part's CONFIG and the part re-renders as a
11
- // draft; lengths, joins and order go into chain.json. Everything the user does is also an event in piece.json,
12
- // which `pamo piece review` hands to the agent.
12
+ // draft; lengths, joins and order go into chain.json; and when the piece already has a joined draft, the studio
13
+ // joins it again by itself. Everything the user does is also an event in piece.json, which `pamo piece review`
14
+ // hands to the agent.
15
+ //
16
+ // The page always says whose turn it is: /api/piece carries whether a command of the agent is waiting on the piece
17
+ // (`agent.listening`), a question it asked in the chat (`ask`), the join in progress (`job`), and whether the
18
+ // joined draft or the final were made from something that has changed since (`media.assemblyStale`, `finalStale`).
13
19
  import fs from 'node:fs';
14
20
  import http from 'node:http';
15
21
  import path from 'node:path';
16
22
  import { fileURLToPath } from 'node:url';
17
- import { pieceDir, JOINS } from './chain.mjs';
18
- import { STAGES, BRIEF_KEYS, readPiece, updatePiece, addEvent, eventLine, sceneTexts, setSceneText } from './piece.mjs';
23
+ 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 { videoView, editVideo, styleView, editStyle, undo, canUndo } from './edit.mjs';
26
+ import { KINDS, EDITABLE, ADDABLE, MULTI, PLAIN, GROUPS, listText, loadRaw, normalize, candidates, scenesOf, describe, applyEdit, evalFill, fillConfig } from './inventory.mjs';
19
27
 
20
28
  const HERE = path.dirname(fileURLToPath(import.meta.url));
21
29
  const PAGE = path.join(HERE, '..', 'studio', 'index.html');
22
30
  const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
23
- const TYPES = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.gif': 'image/gif', '.png': 'image/png', '.jpg': 'image/jpeg', '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript' };
31
+ const TYPES = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.gif': 'image/gif', '.png': 'image/png', '.jpg': 'image/jpeg', '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.woff2': 'font/woff2' };
24
32
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
25
33
 
26
34
  /** Pieces in the project that the studio can show. */
@@ -38,6 +46,111 @@ const fileUrl = (cwd, f) => {
38
46
  return `/files/${path.relative(cwd, f).split(path.sep).map(encodeURIComponent).join('/')}?v=${Math.round(fs.statSync(f).mtimeMs)}`;
39
47
  };
40
48
 
49
+ /** Whether the piece's joined draft and final still show what the piece is now, and the join running on it. */
50
+ export function filmState(cwd, dir) {
51
+ const name = path.basename(dir), sigs = readSigs(dir), sig = chainSig(cwd, dir), renders = path.join(dir, 'renders');
52
+ const has = (f) => fs.existsSync(path.join(renders, f));
53
+ const stale = (f) => has(f) && !!sigs[f]?.sig && sigs[f].sig !== sig; // a render with no record is taken as current
54
+ const job = readJob(dir);
55
+ return {
56
+ draft: has(`${name}-draft.mp4`), final: has(`${name}.mp4`), opts: sigs[`${name}-draft.mp4`] ?? {},
57
+ assemblyStale: stale(`${name}-draft.mp4`), finalStale: stale(`${name}.mp4`),
58
+ job: job ? { kind: job.kind, part: job.part ?? null, of: job.of ?? null, joining: !!job.joining } : null,
59
+ };
60
+ }
61
+ const joinErrors = new Map(); // piece folder → why the studio's last join failed
62
+
63
+ // the blocks and shots that show content and the library's backgrounds, read once when the studio starts (the
64
+ // registry may be the site); LIVE is the scene server the page's live previews play from
65
+ let SCENES = [], BACKGROUNDS = [], TITLES = new Map(), PALETTES = [], FONTPAIRS = [], LIVE = null;
66
+ const building = new Set(), rendering = new Map(); // pieces whose first version or final is being made by the studio
67
+ // a resource's sample (clip and still): the repo's own when the studio runs inside it (new blocks show before the
68
+ // site has them), else the site's
69
+ const LOCAL_PREVIEWS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../apps/web/public/previews');
70
+ const preview = (id, ext) => (fs.existsSync(path.join(LOCAL_PREVIEWS, `${id}.${ext}`)) ? `/previews/${id}.${ext}` : `${SITE}/previews/${id}.${ext}`);
71
+
72
+ // What of a block a person may tune: its numbers and its either/or choices (its words come from the content).
73
+ const NOT_TUNED = new Set(['format', 'background', 'fps', 'codec', 'winner', 'mark', 'detail', 'highlight']);
74
+ function tunables(block, filled, values = {}) {
75
+ return (block.params ?? []).filter((p) => !filled.has(p.name) && !NOT_TUNED.has(p.name) && (p.type === 'enum' || p.type === 'number')).map((p) => {
76
+ const d = p.default, num = p.type === 'number';
77
+ const min = num ? p.min ?? +(d * 0.4).toFixed(2) : null, max = num ? p.max ?? +(d * 1.8).toFixed(2) : null;
78
+ // a setting whose every choice has its own sample (a sentence's entrance) is chosen by watching them
79
+ const demos = block.repeat?.param === p.name && block.repeat.previews ? (p.options ?? []).map((o) => { const r = block.repeat.previews[o]; return { value: o, title: TITLES.get(r) ?? o, clip: r ? preview(r, 'webm') : null, poster: r ? preview(r, 'jpg') : null }; }) : null;
80
+ return { name: p.name, label: (p.description ?? p.name).replace(/^./, (c) => c.toUpperCase()), kind: num ? 'number' : 'enum', options: p.options ?? null, min, max, step: num ? +(Math.max((max - min) / 40, 0.01)).toFixed(2) : null, default: d, value: values[p.name] ?? d, set: p.name in values, demos, varies: block.repeat?.param === p.name };
81
+ });
82
+ }
83
+ // the blocks that can show an item, in the order the page offers them: one that can play several times first
84
+ const ways = (it) => [...candidates(it, SCENES)].sort((a, b) => (b.repeat ? 1 : 0) - (a.repeat ? 1 : 0));
85
+ const filledBy = (block) => new Set(block.needs.flatMap((n) => Object.keys(n.fill ?? {})));
86
+ // the brand's own backgrounds (shader entries, not image filters), as the page shows them
87
+ const brandBackgrounds = (cwd) => (readJson(path.join(cwd, 'brand/shader-set/set.json'), {}).entries ?? []).filter((e) => e.shader && !e.image).map((e) => ({ id: e.id, title: e.id, colors: (e.props?.colors ?? [e.props?.colorBack, e.props?.colorFront]).filter((c) => /^#[0-9a-f]{6}/i.test(c ?? '')).slice(0, 5), entry: { shader: e.shader, props: e.props, background: e.background } }));
88
+
89
+ /**
90
+ * A block as it will really play for one item: installed under pieces/<piece>/.try/, its CONFIG filled with the
91
+ * item's content and the user's tuning, in the brand's look. Returns the address the page plays it from.
92
+ */
93
+ async function tryScene(cwd, piece, itemId, blockId) {
94
+ if (!LIVE) throw new Error('the live preview is not ready yet');
95
+ const dir = pieceDir(piece, cwd), p = readPiece(dir), all = normalize(loadRaw(dir), { cwd }).all;
96
+ const it = all.find((i) => i.id === itemId), b = SCENES.find((x) => x.id === blockId);
97
+ if (!it || !b) throw new Error('no such item or block');
98
+ const need = b.needs[0], fits = (i) => i.kind === need.kind && i.status !== 'removed' && (need.has ?? []).every((f) => i[f] !== undefined && i[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => i[k] === v);
99
+ const group = [it, ...all.filter((i) => i !== it && fits(i))].slice(0, need.count ?? 1);
100
+ const fill = { ...evalFill(need.fill, group, p?.brief ?? {}) };
101
+ for (const n of b.needs.slice(1)) if (n.kind === 'name') Object.assign(fill, evalFill(n.fill, [], p?.brief ?? {}));
102
+ Object.assign(fill, it.tune ?? {});
103
+ const sub = path.join('.try', `${it.id}--${b.id}`), file = path.join(dir, sub, 'scene.html');
104
+ if (!fs.existsSync(file)) {
105
+ const { add } = await import('./registry.mjs');
106
+ const f = String(p?.brief?.format ?? ''), format = /9\s*:\s*16/.test(f) ? '1080x1920' : /1\s*:\s*1/.test(f) ? '1080x1080' : '1920x1080';
107
+ await add([b.id], { cwd, force: true, name: path.join(path.relative(path.join(cwd, 'pieces'), dir), sub), set: (b.params ?? []).some((x) => x.name === 'format') ? [`format=${format}`] : [] });
108
+ }
109
+ fs.writeFileSync(file, fillConfig(fs.readFileSync(file, 'utf8'), fill));
110
+ return { url: `${LIVE}/${path.relative(cwd, file).split(path.sep).map(encodeURIComponent).join('/')}?embed&v=${Date.now()}` };
111
+ }
112
+ /** The piece's inventory for the page: every item with where it stands, what can show it and what it lacks. */
113
+ function inventoryView(cwd, dir) {
114
+ // the page speaks the user's language: everyday names, the fields a person would recognise, labelled
115
+ const kinds = Object.fromEntries(ADDABLE.map((k) => [k, { name: PLAIN[k].name, fields: EDITABLE[k].filter((f) => PLAIN[k].fields[f]).map((f) => [f, PLAIN[k].fields[f], KINDS[k].needs.includes(f)]) }]));
116
+ let raw = [], all = [];
117
+ try { raw = loadRaw(dir); all = normalize(raw, { cwd }).all; } catch (e) { return { items: [], kinds, groups: GROUPS, error: e.message }; }
118
+ const byId = new Map(raw.map((r) => [r.id, r]));
119
+ const mine = brandBackgrounds(cwd), have = new Set(mine.map((m) => m.id));
120
+ // about how many moments the length in the brief holds (a block runs about four seconds, the close is one of them)
121
+ const secs = Number(String(readPiece(dir)?.brief?.format ?? '').match(/(\d+(?:\.\d+)?)\s*(?:s\b|sec|seconds?)/i)?.[1]) || null;
122
+ return { kinds, groups: GROUPS, live: LIVE, moments: secs ? Math.max(2, Math.round((secs - 4) / 4.2) + 1) : null, seconds: secs, backgrounds: { mine, library: BACKGROUNDS.filter((b) => !have.has(b.id)) }, items: all.map((it) => {
123
+ const K = KINDS[it.kind], r = byId.get(it.id) ?? {}, H = PLAIN[it.kind] ?? { name: it.kind, group: 'show', fields: {} };
124
+ const how = (f) => MULTI[it.kind]?.[f] ?? 'text';
125
+ return {
126
+ id: it.id, kind: it.kind, name: H.name, group: H.group, status: it.status, must: !!it.must, what: { line: it.text, voice: it.script, command: it.command }[it.kind] ?? describe(it), source: it.source ?? '', // a sentence or a command is shown whole
127
+ missing: (it.missing ?? []).map((f) => H.fields[f] ?? f),
128
+ // how it could look: the library's own samples of the blocks that show this kind of thing
129
+ // a block that can play several times (a sentence, arriving its own way each time) is the first way to show it
130
+ looks: ways(it).map((b) => ({ id: b.id, title: b.title ?? b.id, summary: b.summary ?? '', poster: preview(b.id, 'jpg'), clip: preview(b.id, 'webm') })),
131
+ // the block the user picked for it (or none: the plan picks), and what of that block they may tune
132
+ show: ways(it).some((b) => b.id === it.show) ? it.show : null, tune: it.tune ?? {},
133
+ tunable: (() => { const c = ways(it), b = c.find((x) => x.id === it.show) ?? c[0]; return b ? tunables(b, filledBy(b), it.tune ?? {}) : []; })(),
134
+ shown: K.role === 'bed' || it.kind === 'logo' ? 'always' : null,
135
+ thumb: it.image ? fileUrl(cwd, path.join(cwd, it.image.replace(/^\//, ''))) : null,
136
+ // [field, value as text, needed, how it is edited, its label]: only what a person would recognise
137
+ fields: Object.entries(H.fields).map(([f, label]) => [f, how(f) === 'text' ? r[f] ?? '' : listText(r[f], how(f)), K.needs.includes(f), how(f), label]),
138
+ };
139
+ }) };
140
+ }
141
+
142
+ // What can be added to a video, by what the user wants to do, each named by what it does; and the transitions.
143
+ const INTENTS = [['say', 'Say something'], ['show', 'Show the product'], ['prove', 'Prove it'], ['how', 'How it works'], ['end', 'End']];
144
+ const JOIN_NAMES = { whip: 'Fast slide', crossfade: 'Soft fade', wipe: 'Wipe', push: 'Push', iris: 'Circle opens', 'zoom-through': 'Zoom through', flash: 'Flash', cells: 'Squares', slices: 'Strips', glitch: 'Glitch', 'flip-3d': 'Card flip', cut: 'Straight cut', dissolve: 'Dissolve', dip: 'Dip to your colour', 'mask-reveal': 'Mask', 'scan-reveal': 'Scan line', 'whip-pan': 'Whip pan' };
145
+ function libraryView() {
146
+ const group = (b) => { const k = b.needs?.[0]?.kind; return !b.needs?.length ? null : k === 'name' || k === 'logo' ? 'end' : PLAIN[k]?.group === 'brand' ? 'end' : PLAIN[k]?.group ?? null; };
147
+ const blocks = SCENES.map((b) => ({ id: b.id, title: b.title, summary: b.summary ?? '', group: group(b), poster: preview(b.id, 'jpg'), clip: preview(b.id, 'webm') })).filter((b) => b.group);
148
+ return {
149
+ intents: INTENTS.map(([id, title]) => ({ id, title, blocks: blocks.filter((b) => b.group === id) })).filter((g) => g.blocks.length),
150
+ joins: Object.keys(JOINS).filter((j) => j !== 'dip' && j !== 'dissolve' || true).map((id) => ({ id, title: JOIN_NAMES[id] ?? id, poster: TITLES.has(id) ? preview(id, 'jpg') : null, clip: TITLES.has(id) ? preview(id, 'webm') : null })),
151
+ };
152
+ }
153
+
41
154
  /** What the page shows for one piece. */
42
155
  export function view(cwd, piece) {
43
156
  const dir = pieceDir(piece, cwd), p = readPiece(dir);
@@ -54,42 +167,90 @@ export function view(cwd, piece) {
54
167
  ...(p.parts?.[c.dir] ?? { status: 'queued' }),
55
168
  };
56
169
  });
57
- const renders = path.join(dir, 'renders'), name = path.basename(dir);
170
+ const renders = path.join(dir, 'renders'), name = path.basename(dir), film = filmState(cwd, dir);
58
171
  const outputs = fs.existsSync(renders) ? fs.readdirSync(renders).filter((f) => !f.startsWith('.') && !f.includes('-draft')).map((f) => ({ file: f, url: fileUrl(cwd, path.join(renders, f)), bytes: fs.statSync(path.join(renders, f)).size })) : [];
59
172
  return {
60
- ...p, dir: path.relative(cwd, dir), format: chain.format ?? null, join: chain.join ?? 'whip', joins: Object.keys(JOINS),
173
+ ...p, boards: p.boards ?? [], dir: path.relative(cwd, dir), format: chain.format ?? null, join: chain.join ?? 'whip', joins: Object.keys(JOINS),
61
174
  parts,
175
+ inventory: inventoryView(cwd, dir),
176
+ // Your video: the parts as the editor shows them, each playing live from the project, and what can be added
177
+ video: (() => {
178
+ const v = videoView(cwd, dir, SCENES); if (!v) return null;
179
+ 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');
181
+ 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
+ return v;
183
+ })(),
184
+ building: building.has(dir), rendering: rendering.get(dir) ?? null, undo: canUndo(dir),
185
+ library: libraryView(),
186
+ style: { ...styleView(cwd), palettes: PALETTES, fontpairs: FONTPAIRS },
62
187
  look: {
63
188
  colors: Object.entries(tokens.colors ?? {}).map(([k, v]) => [k, v?.hex ?? v]),
64
- fonts: Object.entries(tokens.fonts ?? {}).map(([k, v]) => [k, v?.family ?? v]),
189
+ fonts: Object.entries(tokens.fonts ?? {}).map(([k, v]) => [k, v?.family ?? v, v?.css ?? null]),
65
190
  backgrounds: (set.entries ?? []).map((e) => e.id),
66
191
  moves: fs.existsSync(movesDir) ? fs.readdirSync(movesDir).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, '')) : [],
67
192
  },
68
- media: { assembly: fileUrl(cwd, path.join(renders, `${name}-draft.mp4`)), final: fileUrl(cwd, path.join(renders, `${name}.mp4`)), outputs },
193
+ media: { assembly: fileUrl(cwd, path.join(renders, `${name}-draft.mp4`)), final: fileUrl(cwd, path.join(renders, `${name}.mp4`)), outputs,
194
+ assemblyStale: film.assemblyStale, finalStale: film.finalStale, error: joinErrors.get(dir) ?? null },
195
+ job: film.job,
196
+ ask: p.ask ?? null,
197
+ // 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 },
199
+ unread: p.events.filter((e) => e.n > (p.seen ?? 0)).length,
69
200
  };
70
201
  }
71
202
 
203
+ // boards still waiting: ask the site (at most every 4 s per board) whether the user chose, so the studio shows it
204
+ const asked = new Map();
205
+ async function refreshBoards(cwd, piece) {
206
+ const dir = pieceDir(piece, cwd), p = readPiece(dir);
207
+ for (const b of p?.boards ?? []) {
208
+ if (b.status !== 'waiting' || !b.id || Date.now() - (asked.get(b.id) ?? 0) < 4000) continue;
209
+ asked.set(b.id, Date.now());
210
+ try {
211
+ const res = await fetch(`${SITE}/api/boards/${b.id}/choice`, { signal: AbortSignal.timeout(2500) });
212
+ if (res.status === 200) { const { line } = await res.json(); updatePiece(dir, (q) => { const x = q.boards.find((y) => y.id === b.id); if (x) { x.status = 'chosen'; x.line = line; } }); }
213
+ } catch { /* offline: the agent's own wait still gets it */ }
214
+ }
215
+ }
216
+
72
217
  // ── actions from the page ───────────────────────────────────────────────────────────────────
73
218
  function act(cwd, piece, a, queue) {
74
219
  const dir = pieceDir(piece, cwd), chainFile = path.join(dir, 'chain.json');
75
220
  const chain = () => readJson(chainFile, { parts: [] }), saveChain = (c) => fs.writeFileSync(chainFile, JSON.stringify(c, null, 2) + '\n');
76
221
  const text = (v) => String(v ?? '').trim();
77
- let rerender = null;
222
+ const film = filmState(cwd, dir), past = (p, stage) => STAGES.indexOf(p.stage) > STAGES.indexOf(stage);
223
+ let rerender = null, keep = false;
224
+ // an edit by hand re-renders a part; one that was approved, in a piece already past Parts, stays approved
225
+ const redo = (p, part) => { keep = p.parts[part]?.status === 'approved' && past(p, 'parts'); p.parts[part] = { ...(p.parts[part] ?? {}), status: 'queued' }; rerender = part; };
78
226
  updatePiece(dir, (p) => {
79
227
  switch (a.type) {
80
228
  case 'approve': { // a stage
81
229
  if (!STAGES.includes(a.stage)) throw new Error('bad stage');
82
230
  if (a.stage === 'parts' && Object.values(p.parts).some((x) => x.status !== 'approved')) throw new Error('approve every part first');
231
+ if (a.stage === 'inventory') {
232
+ const inv = loadRaw(dir);
233
+ if (inv.some((i) => i.status === 'proposed')) throw new Error('keep or remove every proposal first');
234
+ if (!inv.some((i) => !i.status || i.status === 'kept')) throw new Error('there is nothing to show yet');
235
+ const short = normalize(inv, { cwd }).items.filter((i) => i.missing.length);
236
+ if (short.length) throw new Error(`${short[0].id} still needs ${short[0].missing.join(', ')}: fill it in or remove it`);
237
+ }
238
+ // never approve a film that no longer shows the piece as it is
239
+ if (a.stage === 'video' && !fs.existsSync(path.join(dir, 'chain.json'))) throw new Error('there is no video yet');
240
+ if (['assembly', 'final'].includes(a.stage)) {
241
+ if (film.job?.kind === a.stage || film[`${a.stage}Stale`]) throw new Error('this video is being updated: wait for the new one');
242
+ if (Object.values(p.parts).some((x) => ['changes', 'queued', 'rendering'].includes(x.status))) throw new Error('a part is still changing: wait for the new video');
243
+ }
83
244
  p.stages[a.stage] = { status: 'approved', at: new Date().toISOString() };
84
245
  const next = STAGES[STAGES.indexOf(a.stage) + 1];
85
- if (next) { p.stage = next; if (p.stages[next].status === 'pending') p.stages[next] = { status: 'working' }; }
246
+ if (next) { p.stage = next; if (p.stages[next].status === 'pending') p.stages[next] = { status: 'working', since: new Date().toISOString() }; }
86
247
  addEvent(p, { kind: 'approve', stage: a.stage });
87
248
  break;
88
249
  }
89
250
  case 'changes': { // a stage, or a part when a.part
90
251
  const t = text(a.text); if (!t) throw new Error('say what to change');
91
252
  if (a.part) { p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'changes', note: t }; addEvent(p, { kind: 'changes', stage: 'parts', part: a.part, text: t }); }
92
- else { p.stages[a.stage] = { status: 'changes', note: t }; p.stage = a.stage; addEvent(p, { kind: 'changes', stage: a.stage, text: t }); }
253
+ else { p.stages[a.stage] = { status: 'changes', note: t, since: new Date().toISOString() }; p.stage = a.stage; addEvent(p, { kind: 'changes', stage: a.stage, text: t }); }
93
254
  break;
94
255
  }
95
256
  case 'reopen': { p.stages[a.stage] = { status: 'review' }; addEvent(p, { kind: 'reopen', stage: a.stage }); break; }
@@ -103,6 +264,33 @@ function act(cwd, piece, a, queue) {
103
264
  addEvent(p, { kind: 'edit', stage: 'brief', key: a.key, text: t });
104
265
  break;
105
266
  }
267
+ case 'inv-show': case 'inv-tune': { // how an item is shown: the block, its settings, its background
268
+ const it = normalize(loadRaw(dir), { cwd }).all.find((i) => i.id === a.id); if (!it) throw new Error('no such item');
269
+ const c = ways(it);
270
+ if (a.type === 'inv-show') { const b = c.find((x) => x.id === a.value); if (a.value && !b) throw new Error('that block cannot show this'); a.title = b?.title; }
271
+ else {
272
+ const b = c.find((x) => x.id === (a.block ?? it.show)) ?? c[0]; if (!b) throw new Error('nothing shows this item yet');
273
+ if (a.key === 'background') { if (a.value && !brandBackgrounds(cwd).some((m) => m.id === a.value)) throw new Error('that background is not in your brand yet: add it first'); a.label = 'background'; }
274
+ else {
275
+ const t = tunables(b, filledBy(b)).find((x) => x.name === a.key); if (!t) throw new Error(`"${a.key}" is not a setting of ${b.title ?? b.id}`);
276
+ if (a.value !== '' && a.value != null) { if (t.kind === 'number') { const n = Number(a.value); if (!Number.isFinite(n)) throw new Error('a number, please'); a.value = Math.min(t.max, Math.max(t.min, n)); } else if (!t.options.includes(a.value)) throw new Error('not one of the choices'); }
277
+ a.label = t.label;
278
+ // tuning a block means wanting that block: it stays the one that shows this item
279
+ if (it.show !== b.id) applyEdit(dir, { type: 'inv-show', id: it.id, value: b.id, title: b.title });
280
+ }
281
+ }
282
+ const said = applyEdit(dir, a);
283
+ if (p.stages.inventory.status === 'approved') p.stages.inventory = { status: 'review', note: 'edited by hand' };
284
+ addEvent(p, { kind: 'edit', stage: 'inventory', key: said.key, text: said.text, say: said.human });
285
+ break;
286
+ }
287
+ case 'inv-status': case 'inv-rest': case 'inv-must': case 'inv-field': case 'inv-add': { // the inventory, by hand
288
+ const said = applyEdit(dir, a);
289
+ // an approved inventory that changes comes back for the user to confirm (and the plan made from it is old)
290
+ if (p.stages.inventory.status === 'approved') p.stages.inventory = { status: 'review', note: 'edited by hand' };
291
+ addEvent(p, { kind: 'edit', stage: 'inventory', key: said.key, text: said.text, say: said.human }); // `say`: the same, in the user's words, for the page
292
+ break;
293
+ }
106
294
  case 'what': { // a part's description: a note for the agent, kept in chain.json
107
295
  const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
108
296
  part.what = text(a.value); saveChain(c);
@@ -112,15 +300,18 @@ function act(cwd, piece, a, queue) {
112
300
  case 'text': { // a word on screen: straight into the scene's CONFIG, then a fresh draft
113
301
  const f = path.join(dir, a.part, 'scene.html');
114
302
  fs.writeFileSync(f, setSceneText(fs.readFileSync(f, 'utf8'), a.key, text(a.value)));
115
- p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'queued' };
303
+ redo(p, a.part);
116
304
  addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: a.key, text: text(a.value), rerendered: true });
117
- rerender = a.part;
118
305
  break;
119
306
  }
120
307
  case 'seconds':
121
308
  case 'join': {
122
309
  const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
123
- if (a.type === 'seconds') { part.seconds = Math.max(0.5, Math.min(30, Number(a.value))); rerender = a.part; p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'queued' }; }
310
+ if (a.type === 'seconds') {
311
+ const n = Number(a.value);
312
+ if (!String(a.value ?? '').trim() || !Number.isFinite(n)) throw new Error('give a length in seconds');
313
+ part.seconds = Math.max(0.5, Math.min(30, n)); redo(p, a.part);
314
+ }
124
315
  else { if (!(a.value in JOINS)) throw new Error('bad join'); part.join = a.value; }
125
316
  saveChain(c);
126
317
  addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: a.type, text: String(a.type === 'seconds' ? part.seconds : part.join), rerendered: a.type === 'seconds' });
@@ -136,33 +327,106 @@ function act(cwd, piece, a, queue) {
136
327
  default: throw new Error(`unknown action ${a.type}`);
137
328
  }
138
329
  });
139
- // in Assembly, a new order, length or join means the joined version is stale: hand it back to the agent
140
- if (['order', 'seconds', 'join'].includes(a.type)) updatePiece(dir, (p) => {
141
- if (p.stage !== 'assembly') return;
142
- p.stages.assembly = { status: 'changes', note: 'edited by hand: join the parts again' };
143
- p.events.at(-1).rejoin = true;
144
- });
145
- if (rerender) queue(piece, rerender);
330
+ // An edit by hand changes what the joined video shows. A draft that exists is joined again here, by the studio
331
+ // (no judgment needed, so no waiting for the agent). The full-quality final is the agent's: it goes back to it.
332
+ if (['text', 'order', 'seconds', 'join'].includes(a.type)) {
333
+ updatePiece(dir, (p) => {
334
+ const ev = p.events.at(-1);
335
+ if (film.draft) ev.rejoined = true;
336
+ if (p.stages.final.status !== 'pending') { // the final was reached: it no longer shows the piece
337
+ p.stages.final = { status: 'changes', note: 'You edited the piece by hand, so the final needs a new render.', since: new Date().toISOString() };
338
+ ev.refinal = true;
339
+ }
340
+ });
341
+ joinErrors.delete(dir);
342
+ }
343
+ if (rerender) queue.part(piece, rerender, keep);
344
+ if (film.draft && ['text', 'order', 'seconds', 'join'].includes(a.type)) queue.join(piece);
146
345
  const p = readPiece(dir);
147
346
  return { ok: true, line: eventLine(p, p.events.at(-1)) };
148
347
  }
149
348
 
150
- /** One part at a time re-renders as a draft after an edit (the engine needs no agent for that). */
349
+ // ── Your video: edits that need no judgment, made here; each reaches the agent as one line ──────────────────────
350
+ async function actVideo(cwd, piece, a, log) {
351
+ const dir = pieceDir(piece, cwd), p = readPiece(dir); if (!p) throw new Error('no such piece');
352
+ let said;
353
+ if (a.type === 'undo') said = { say: `Undid ${undo(cwd, dir)}` };
354
+ else if (a.type === 'render') { renderFinal(cwd, piece, log); said = { say: 'Rendering the final' }; }
355
+ else if (a.type.startsWith('style-')) said = await editStyle(cwd, dir, { ...a, type: a.type.slice(6) });
356
+ else said = await editVideo(cwd, dir, { ...a, type: a.type.slice(2) }, SCENES, { ...p.brief, title: p.title });
357
+ updatePiece(dir, (q) => {
358
+ // an edit to a video already approved sends it back to the user, and a final made before it is out of date
359
+ if (a.type !== 'render') {
360
+ if (q.stages.video?.status === 'approved') q.stages.video = { status: 'review', note: 'edited by hand' };
361
+ if (q.stages.final?.status && q.stages.final.status !== 'pending') q.stages.final = { status: 'changes', note: 'You changed the video, so the final needs a new render.', since: new Date().toISOString() };
362
+ }
363
+ addEvent(q, { kind: 'edit', stage: 'video', key: a.type, text: said.say, say: said.say });
364
+ });
365
+ return { ok: true, ...said };
366
+ }
367
+ function buildFirst(cwd, piece, log) {
368
+ const dir = pieceDir(piece, cwd);
369
+ if (fs.existsSync(path.join(dir, 'chain.json')) || building.has(dir)) return;
370
+ building.add(dir);
371
+ const p = readPiece(dir);
372
+ editVideo(cwd, dir, { type: 'build' }, SCENES, { ...p.brief, title: p.title })
373
+ .then((r) => updatePiece(dir, (q) => { q.stage = 'video'; q.stages.video = { status: 'review', since: new Date().toISOString() }; addEvent(q, { kind: 'edit', stage: 'video', key: 'build', text: r.say, say: r.say }); }))
374
+ .catch((e) => { log(`✗ building ${piece}: ${e.message}`); joinErrors.set(dir, e.message.split('\n')[0]); })
375
+ .finally(() => building.delete(dir));
376
+ }
377
+ function renderFinal(cwd, piece, log) {
378
+ const dir = pieceDir(piece, cwd); if (rendering.has(dir)) return;
379
+ rendering.set(dir, { started: Date.now() });
380
+ import('./chain.mjs').then((c) => c.renderChain({ cwd, piece, motionBlur: 5, onStep: (e) => { if (e.part) rendering.set(dir, { started: rendering.get(dir)?.started, part: e.part, of: e.of }); else if (e.joining) rendering.set(dir, { started: rendering.get(dir)?.started, joining: true }); } }))
381
+ .then((r) => updatePiece(dir, (q) => { q.stage = 'final'; q.stages.final = { status: 'review', since: new Date().toISOString() }; addEvent(q, { kind: 'edit', stage: 'final', key: 'render', text: `rendered ${path.basename(r.out)}`, say: 'The final is ready' }); }))
382
+ .catch((e) => { log(`✗ final ${piece}: ${e.message}`); joinErrors.set(dir, e.message.split('\n')[0]); })
383
+ .finally(() => rendering.delete(dir));
384
+ }
385
+
386
+ /**
387
+ * The studio's own renders after an edit by hand, one at a time: parts first (each as a draft), then, once no part
388
+ * is left, the joined draft again with the sound it was made with. The engine needs no agent for either.
389
+ */
151
390
  function renderQueue(cwd, log) {
152
- const q = [];
391
+ const parts = [], joins = new Set();
153
392
  let busy = false;
154
393
  const next = async () => {
155
- if (busy || !q.length) return;
394
+ if (busy || (!parts.length && !joins.size)) return;
156
395
  busy = true;
157
- const [piece, part] = q.shift();
158
- try {
159
- const { renderDrafts } = await import('./chain.mjs');
160
- await renderDrafts({ cwd, piece, only: [part] });
161
- log(`re-rendered ${piece}/${part}`);
162
- } catch (e) { log(`✗ ${piece}/${part}: ${e.message}`); }
396
+ const chainLib = await import('./chain.mjs');
397
+ if (parts.length) {
398
+ const [piece, part, keep] = parts.shift(), dir = pieceDir(piece, cwd);
399
+ try {
400
+ await chainLib.renderDrafts({ cwd, piece, only: [part] });
401
+ if (keep) updatePiece(dir, (p) => { if (p.parts[part]?.status === 'review' && !p.parts[part].error) p.parts[part].status = 'approved'; });
402
+ log(`re-rendered ${piece}/${part}`);
403
+ } catch (e) { log(`✗ ${piece}/${part}: ${e.message}`); }
404
+ } else {
405
+ const piece = joins.values().next().value, dir = pieceDir(piece, cwd);
406
+ joins.delete(piece);
407
+ try {
408
+ // a join the agent already started covers this edit only if it began after it: wait for it, then look again
409
+ while (chainLib.readJob(dir)?.kind === 'assembly') await new Promise((r) => setTimeout(r, 1000));
410
+ const film = filmState(cwd, dir);
411
+ if (film.assemblyStale || !film.opts.sig) {
412
+ await chainLib.renderChain({ cwd, piece, draft: true, sfx: film.opts.sfx ?? true, audio: film.opts.audio });
413
+ log(`joined ${piece} again`);
414
+ }
415
+ joinErrors.delete(dir);
416
+ } catch (e) {
417
+ // the studio could not: say so on the page and hand the join to the agent
418
+ const why = String(e.message ?? e).split('\n')[0].slice(0, 240);
419
+ log(`✗ join ${piece}: ${why}`);
420
+ joinErrors.set(dir, why);
421
+ try { updatePiece(dir, (p) => { addEvent(p, { kind: 'failed', stage: 'assembly', text: why, rejoin: true }); }); } catch { /* the piece is gone */ }
422
+ }
423
+ }
163
424
  busy = false; next();
164
425
  };
165
- return (piece, part) => { if (!q.some(([a, b]) => a === piece && b === part)) q.push([piece, part]); next(); };
426
+ return {
427
+ part: (piece, part, keep = false) => { if (!parts.some(([a, b]) => a === piece && b === part)) parts.push([piece, part, keep]); next(); },
428
+ join: (piece) => { joins.add(piece); next(); },
429
+ };
166
430
  }
167
431
 
168
432
  function sendFile(req, res, f) {
@@ -180,22 +444,72 @@ function sendFile(req, res, f) {
180
444
 
181
445
  export function startStudio({ cwd = process.cwd(), port = 4317, log = console.log } = {}) {
182
446
  const queue = renderQueue(cwd, log);
447
+ import('./registry.mjs').then((r) => r.getIndex()).then((i) => { // offline: items show without their blocks
448
+ SCENES = scenesOf(i); TITLES = new Map((i.items ?? []).map((x) => [x.id, x.title]));
449
+ PALETTES = (i.items ?? []).filter((x) => x.type === 'palette').map((x) => ({ id: x.id, title: x.title, colors: x.colors ?? [] }));
450
+ FONTPAIRS = (i.items ?? []).filter((x) => x.type === 'font-pair').map((x) => ({ id: x.id, title: x.title }));
451
+ 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
+ }).catch(() => {});
453
+ // the page's live previews (a block with the user's content, a background) play from a scene server on this project
454
+ import('./server.mjs').then((m) => m.startServer({ root: cwd })).then((sv) => { LIVE = `http://127.0.0.1:${sv.port}`; }).catch((e) => log(`live previews are off: ${e.message}`));
183
455
  const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
184
456
  const server = http.createServer(async (req, res) => {
185
457
  const u = new URL(req.url, 'http://x');
186
458
  try {
187
459
  if (u.pathname === '/' || u.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' }); return res.end(fs.readFileSync(PAGE)); }
460
+ if (u.pathname.startsWith('/fonts/')) { // the studio's own typefaces, served locally so it works offline
461
+ const f = path.join(path.dirname(PAGE), 'fonts', path.basename(u.pathname));
462
+ if (!fs.existsSync(f)) { res.writeHead(404); return res.end(); }
463
+ res.writeHead(200, { 'Content-Type': TYPES[path.extname(f)] ?? 'application/octet-stream', 'Cache-Control': 'max-age=86400' });
464
+ return res.end(fs.readFileSync(f));
465
+ }
188
466
  if (u.pathname === '/api/pieces') return json(res, 200, listPieces(cwd));
189
467
  if (u.pathname === '/api/info') return json(res, 200, { app: 'paper-motion-studio', cwd: path.resolve(cwd), pid: process.pid });
190
468
  if (u.pathname === '/api/piece') {
191
469
  const piece = u.searchParams.get('p');
192
470
  if (req.method === 'POST') {
193
471
  let body = ''; for await (const c of req) body += c;
194
- try { return json(res, 200, act(cwd, piece, JSON.parse(body || '{}'), queue)); } catch (e) { return json(res, 400, { error: e.message }); }
472
+ try {
473
+ const a = JSON.parse(body || '{}');
474
+ if (/^(v-|style-)|^undo$|^render$/.test(a.type)) return json(res, 200, await actVideo(cwd, piece, a, log));
475
+ 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);
478
+ return json(res, 200, r);
479
+ } catch (e) { return json(res, 400, { error: e.message.split('\n')[0] }); }
195
480
  }
481
+ await refreshBoards(cwd, piece);
196
482
  const v = view(cwd, piece);
197
483
  return v ? json(res, 200, v) : json(res, 404, { error: `no piece "${piece}"` });
198
484
  }
485
+ if (u.pathname.startsWith('/previews/')) { // the repo's own samples (see preview())
486
+ const f = path.join(LOCAL_PREVIEWS, path.basename(u.pathname));
487
+ if (!fs.existsSync(f)) { res.writeHead(404); return res.end(); }
488
+ return sendFile(req, res, f);
489
+ }
490
+ if (u.pathname === '/api/try' && req.method === 'POST') { // a block playing live with one item's content
491
+ let body = ''; for await (const c of req) body += c;
492
+ 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] }); }
493
+ }
494
+ if (u.pathname === '/api/background' && req.method === 'POST') { // a library background into the brand, in its colours
495
+ let body = ''; for await (const c of req) body += c;
496
+ try {
497
+ const a = JSON.parse(body || '{}'), b = BACKGROUNDS.find((x) => x.id === a.id); if (!b) throw new Error('no such background');
498
+ const { add } = await import('./registry.mjs');
499
+ await add([b.id], { cwd, palette: 'brand' });
500
+ const dir = pieceDir(u.searchParams.get('p'), cwd);
501
+ updatePiece(dir, (p) => { addEvent(p, { kind: 'edit', stage: 'inventory', key: 'brand background', text: b.id, say: `Added a background to your brand: ${b.title}` }); });
502
+ return json(res, 200, { ok: true });
503
+ } catch (e) { return json(res, 400, { error: e.message.split('\n')[0] }); }
504
+ }
505
+ if (u.pathname === '/api/focus' && req.method === 'POST') {
506
+ // back to the agent: the app its CLI runs in, as recorded in piece.json by that CLI
507
+ const app = readPiece(pieceDir(u.searchParams.get('p') ?? '', cwd))?.agent?.path;
508
+ if (process.platform !== 'darwin' || !app || !app.endsWith('.app') || !fs.existsSync(app)) return json(res, 400, { error: 'no app to open' });
509
+ const { spawn } = await import('node:child_process');
510
+ spawn('open', ['-a', app], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
511
+ return json(res, 200, { ok: true });
512
+ }
199
513
  if (u.pathname.startsWith('/files/')) {
200
514
  const f = path.resolve(cwd, decodeURIComponent(u.pathname.slice(7)));
201
515
  if (!f.startsWith(path.resolve(cwd) + path.sep) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
package/runtime/index.js CHANGED
@@ -144,6 +144,13 @@ const imageCache = new Map();
144
144
  // A transparent pixel stands in for an image shader with no image (PaperTexture and Water also work standalone).
145
145
  const BLANK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
146
146
 
147
+ // A scene shown inside another page (?embed: the studio's live previews) draws its shaders at the size it is shown,
148
+ // not at the frame's full size times the screen's density (8 million pixels a frame on a Retina screen for a box
149
+ // 700 px wide). Renders never pass ?embed, so they keep every pixel.
150
+ const EMBED_PIXELS = typeof location !== 'undefined' && /[?&]embed(?:[=&]|$)/.test(location.search)
151
+ ? Math.max(480 * 270, Math.round(innerWidth * innerHeight * Math.max(1, globalThis.devicePixelRatio ?? 1) ** 2))
152
+ : null;
153
+
147
154
  class ShaderLayer {
148
155
  /**
149
156
  * @param el element the canvas fills (give it a size: e.g. position:absolute; inset:0)
@@ -177,7 +184,7 @@ class ShaderLayer {
177
184
  { preserveDrawingBuffer: true, ...(props.webGlContextAttributes ?? {}) },
178
185
  0, this.frame0,
179
186
  this.options.minPixelRatio ?? 2,
180
- this.options.maxPixelCount ?? 3840 * 2160 * 2,
187
+ this.options.maxPixelCount ?? EMBED_PIXELS ?? 3840 * 2160 * 2,
181
188
  props.mipmaps,
182
189
  );
183
190
  if (this.options.loop) {
@@ -220,6 +227,8 @@ class ShaderLayer {
220
227
 
221
228
  seek(t) {
222
229
  if (!this.a) return;
230
+ // embedded, a layer no one can see (a later shot waiting at opacity 0, display none) is not drawn this frame
231
+ if (EMBED_PIXELS && this.el.checkVisibility && !this.el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return;
223
232
  const time = this.time ?? (t + (this.options.timeOffset ?? 0));
224
233
  const ms = (s) => this.frame0 + s * 1000 * this.speed;
225
234
  const L = this.options.loop;
package/runtime/player.js CHANGED
@@ -1,10 +1,15 @@
1
1
  // Preview player: real-time playback, scrubbing and frame stepping.
2
- // Only mounted in preview (never in renders).
2
+ // Only mounted in preview (never in renders). With ?embed in the address it plays and loops with no controls,
3
+ // for a scene shown inside another page (the studio's live previews).
3
4
  // space play/pause · ←/→ one frame · shift+←/→ one second · home start · L loop · H hide UI
4
5
 
5
6
  export function mountPlayer(scene, startT = 0) {
6
7
  const { stage, duration, fps } = scene;
7
- let t = startT, playing = true, loop = true, last = performance.now(), busy = false;
8
+ const q = new URLSearchParams(location.search), embed = q.has('embed');
9
+ // a part longer or shorter than its scene plays the whole scene slower or faster (the studio passes its rate)
10
+ const rate = Math.min(4, Math.max(0.25, Number(q.get('rate')) || 1));
11
+ // embedded, a scene plays once and holds its last frame (a click plays it again); on its own page it loops
12
+ let t = startT, playing = true, loop = !embed, last = performance.now(), busy = false;
8
13
 
9
14
  const bar = document.createElement('div');
10
15
  bar.innerHTML = `
@@ -25,10 +30,11 @@ export function mountPlayer(scene, startT = 0) {
25
30
  const range = bar.querySelector('input');
26
31
  const label = bar.querySelector('.pm-time');
27
32
  loopBtn.classList.toggle('on', loop);
33
+ if (embed) bar.style.display = 'none';
28
34
 
29
35
  function fit() {
30
36
  const avail = { w: innerWidth, h: innerHeight - (bar.style.display === 'none' ? 0 : 44) };
31
- const s = Math.min(avail.w / scene.width, avail.h / scene.height, 1);
37
+ const s = Math.min(avail.w / scene.width, avail.h / scene.height, embed ? Infinity : 1);
32
38
  stage.style.transform = `translate(${(avail.w - scene.width * s) / 2}px, ${(avail.h - scene.height * s) / 2}px) scale(${s})`;
33
39
  }
34
40
  addEventListener('resize', fit); fit();
@@ -37,15 +43,20 @@ export function mountPlayer(scene, startT = 0) {
37
43
  t = Math.max(0, Math.min(duration, nt));
38
44
  range.value = t;
39
45
  label.textContent = `${t.toFixed(2)}s · f${String(Math.round(t * fps)).padStart(4, '0')} / ${Math.round(duration * fps)}`;
40
- try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ }
46
+ if (!embed) { try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ } }
41
47
  busy = true; await scene.seek(t); busy = false;
42
48
  }
43
49
  function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); }
44
50
 
51
+ // embedded, it draws at most 30 frames a second: a preview beside an editor, not the render
52
+ const step = embed ? 1000 / 30 - 2 : 0;
53
+ let drawn = 0;
45
54
  function tick(now) {
55
+ if (embed && now - drawn < step) { requestAnimationFrame(tick); return; }
56
+ drawn = now;
46
57
  if (playing && !busy) {
47
- let nt = t + (now - last) / 1000;
48
- if (nt >= duration) { if (loop) nt %= duration; else { nt = duration; setPlaying(false); } }
58
+ let nt = t + ((now - last) / 1000) * rate;
59
+ if (nt >= duration) { if (loop) nt %= duration; else { nt = duration - 1 / fps; setPlaying(false); if (embed) { try { parent.postMessage({ pm: 'ended' }, '*'); } catch { /* no parent */ } } } }
49
60
  show(nt);
50
61
  }
51
62
  last = now;
@@ -54,6 +65,7 @@ export function mountPlayer(scene, startT = 0) {
54
65
  requestAnimationFrame(tick);
55
66
 
56
67
  playBtn.onclick = () => setPlaying(!playing);
68
+ if (embed) addEventListener('click', () => { if (!playing) show(0); setPlaying(true); });
57
69
  loopBtn.onclick = () => { loop = !loop; loopBtn.classList.toggle('on', loop); };
58
70
  range.oninput = () => { setPlaying(false); show(Number(range.value)); };
59
71
  addEventListener('keydown', (e) => {