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.
- package/CHANGELOG.md +78 -0
- package/bin/pamo.mjs +62 -5
- package/lib/edit.mjs +31 -3
- package/lib/piece.mjs +121 -10
- package/lib/registry.mjs +10 -4
- package/lib/studio.mjs +195 -7
- package/package.json +2 -1
- package/runtime/brand.js +14 -2
- package/runtime/player.js +4 -2
- package/studio/index.html +236 -57
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/examples/breath/stills/t000.00.png +0 -0
- package/examples/breath/stills/t002.00.png +0 -0
- package/examples/breath/stills/t004.00.png +0 -0
- package/examples/breath/stills/t006.00.png +0 -0
- package/examples/breath/stills/t008.00.png +0 -0
- package/examples/breath/stills/t010.00.png +0 -0
- package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
- package/examples/chrome-endcard/stills/t000.00.png +0 -0
- package/examples/chrome-endcard/stills/t001.33.png +0 -0
- package/examples/chrome-endcard/stills/t002.67.png +0 -0
- package/examples/chrome-endcard/stills/t004.00.png +0 -0
- package/examples/chrome-endcard/stills/t005.33.png +0 -0
- package/examples/chrome-endcard/stills/t006.67.png +0 -0
- package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
- package/examples/halo-explainer/stills/t000.00.png +0 -0
- package/examples/halo-explainer/stills/t006.00.png +0 -0
- package/examples/halo-explainer/stills/t012.00.png +0 -0
- package/examples/halo-explainer/stills/t018.00.png +0 -0
- package/examples/halo-explainer/stills/t024.00.png +0 -0
- package/examples/halo-explainer/stills/t030.00.png +0 -0
- package/examples/install-loop/stills/contact-sheet.jpg +0 -0
- package/examples/install-loop/stills/t000.00.png +0 -0
- package/examples/install-loop/stills/t001.00.png +0 -0
- package/examples/install-loop/stills/t002.00.png +0 -0
- package/examples/install-loop/stills/t003.00.png +0 -0
- package/examples/install-loop/stills/t004.00.png +0 -0
- package/examples/install-loop/stills/t005.00.png +0 -0
- package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
- package/examples/orbit-keynote/stills/t000.00.png +0 -0
- package/examples/orbit-keynote/stills/t006.50.png +0 -0
- package/examples/orbit-keynote/stills/t013.00.png +0 -0
- package/examples/orbit-keynote/stills/t019.50.png +0 -0
- package/examples/orbit-keynote/stills/t026.00.png +0 -0
- package/examples/orbit-keynote/stills/t032.50.png +0 -0
- package/examples/signal/stills/contact-sheet.jpg +0 -0
- package/examples/signal/stills/t000.00.png +0 -0
- package/examples/signal/stills/t001.67.png +0 -0
- package/examples/signal/stills/t003.33.png +0 -0
- package/examples/signal/stills/t005.00.png +0 -0
- package/examples/signal/stills/t006.67.png +0 -0
- package/examples/signal/stills/t008.33.png +0 -0
package/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
477
|
-
if (a.type === '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); });
|