paper-motion 0.3.0 → 0.4.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 +108 -0
- package/bin/pamo.mjs +221 -20
- 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/chain.mjs +75 -7
- package/lib/customize.mjs +40 -2
- package/lib/edit.mjs +326 -0
- package/lib/gather.mjs +241 -0
- package/lib/host.mjs +25 -0
- package/lib/inventory.mjs +523 -0
- package/lib/piece.mjs +93 -17
- package/lib/registry.mjs +22 -6
- package/lib/studio.mjs +347 -33
- package/package.json +1 -1
- package/runtime/player.js +6 -3
- package/runtime/swatch.html +21 -0
- package/studio/fonts/GeistPixel-Square.woff2 +0 -0
- package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
- package/studio/index.html +439 -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
|
|
12
|
-
// which `pamo piece review`
|
|
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&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
|
-
|
|
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
|
|
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') {
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
p
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
391
|
+
const parts = [], joins = new Set();
|
|
153
392
|
let busy = false;
|
|
154
393
|
const next = async () => {
|
|
155
|
-
if (busy || !
|
|
394
|
+
if (busy || (!parts.length && !joins.size)) return;
|
|
156
395
|
busy = true;
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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 {
|
|
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
package/runtime/player.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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;
|
|
8
|
+
const embed = new URLSearchParams(location.search).has('embed');
|
|
7
9
|
let t = startT, playing = true, loop = true, last = performance.now(), busy = false;
|
|
8
10
|
|
|
9
11
|
const bar = document.createElement('div');
|
|
@@ -25,10 +27,11 @@ export function mountPlayer(scene, startT = 0) {
|
|
|
25
27
|
const range = bar.querySelector('input');
|
|
26
28
|
const label = bar.querySelector('.pm-time');
|
|
27
29
|
loopBtn.classList.toggle('on', loop);
|
|
30
|
+
if (embed) bar.style.display = 'none';
|
|
28
31
|
|
|
29
32
|
function fit() {
|
|
30
33
|
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);
|
|
34
|
+
const s = Math.min(avail.w / scene.width, avail.h / scene.height, embed ? Infinity : 1);
|
|
32
35
|
stage.style.transform = `translate(${(avail.w - scene.width * s) / 2}px, ${(avail.h - scene.height * s) / 2}px) scale(${s})`;
|
|
33
36
|
}
|
|
34
37
|
addEventListener('resize', fit); fit();
|
|
@@ -37,7 +40,7 @@ export function mountPlayer(scene, startT = 0) {
|
|
|
37
40
|
t = Math.max(0, Math.min(duration, nt));
|
|
38
41
|
range.value = t;
|
|
39
42
|
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 */ }
|
|
43
|
+
if (!embed) { try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ } }
|
|
41
44
|
busy = true; await scene.seek(t); busy = false;
|
|
42
45
|
}
|
|
43
46
|
function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>swatch</title>
|
|
6
|
+
<style>html, body { margin: 0; height: 100%; background: #000; overflow: hidden; } #s { position: absolute; inset: 0; }</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="s"></div>
|
|
10
|
+
<script type="module">
|
|
11
|
+
// One background playing live, for the studio's small demos: the entry (shader, props, background) rides in the hash.
|
|
12
|
+
import { live } from 'paper-motion';
|
|
13
|
+
try {
|
|
14
|
+
const e = JSON.parse(decodeURIComponent(location.hash.slice(1)));
|
|
15
|
+
const el = document.getElementById('s');
|
|
16
|
+
if (e.background) el.style.background = e.background;
|
|
17
|
+
await live(el, e.shader, e.props ?? {});
|
|
18
|
+
} catch (err) { document.body.style.background = '#111'; console.warn(err); }
|
|
19
|
+
</script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
Binary file
|
|
Binary file
|