paper-motion 0.2.7 → 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 +119 -0
- package/bin/pamo.mjs +332 -10
- 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 +366 -0
- 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/live-board.js +3 -1
- package/lib/piece.mjs +223 -0
- package/lib/registry.mjs +45 -11
- package/lib/studio.mjs +572 -0
- package/package.json +2 -1
- package/runtime/brand.js +1 -1
- package/runtime/index.js +1 -0
- 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 +503 -0
package/lib/studio.mjs
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// pamo studio: a local page where the user follows a piece through its stages, watches each stage's renders,
|
|
2
|
+
// approves or asks for changes, and edits the brief, the words on screen, lengths, joins and order by hand.
|
|
3
|
+
//
|
|
4
|
+
// GET / the studio page (studio/index.html)
|
|
5
|
+
// GET /api/pieces pieces in this project that have a piece.json
|
|
6
|
+
// GET /api/piece?p=<piece> everything the page shows: piece.json + chain + look + media
|
|
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)
|
|
9
|
+
// GET /files/<path> files under the project (renders, drafts), with byte ranges for video
|
|
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
|
|
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`).
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
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';
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const PAGE = path.join(HERE, '..', 'studio', 'index.html');
|
|
30
|
+
const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
|
|
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' };
|
|
32
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
33
|
+
|
|
34
|
+
/** Pieces in the project that the studio can show. */
|
|
35
|
+
export function listPieces(cwd) {
|
|
36
|
+
const root = path.join(cwd, 'pieces');
|
|
37
|
+
if (!fs.existsSync(root)) return [];
|
|
38
|
+
return fs.readdirSync(root).filter((d) => fs.existsSync(path.join(root, d, 'piece.json'))).map((d) => {
|
|
39
|
+
const p = readJson(path.join(root, d, 'piece.json'), {});
|
|
40
|
+
return { piece: d, title: p.title, stage: p.stage, status: p.stages?.[p.stage]?.status, updated: p.updated };
|
|
41
|
+
}).sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fileUrl = (cwd, f) => {
|
|
45
|
+
if (!fs.existsSync(f)) return null;
|
|
46
|
+
return `/files/${path.relative(cwd, f).split(path.sep).map(encodeURIComponent).join('/')}?v=${Math.round(fs.statSync(f).mtimeMs)}`;
|
|
47
|
+
};
|
|
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
|
+
|
|
154
|
+
/** What the page shows for one piece. */
|
|
155
|
+
export function view(cwd, piece) {
|
|
156
|
+
const dir = pieceDir(piece, cwd), p = readPiece(dir);
|
|
157
|
+
if (!p) return null;
|
|
158
|
+
const chain = readJson(path.join(dir, 'chain.json'), { parts: [] });
|
|
159
|
+
const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {});
|
|
160
|
+
const movesDir = path.join(cwd, 'moves');
|
|
161
|
+
const parts = (chain.parts ?? []).map((c) => {
|
|
162
|
+
const html = c.dir ? (() => { try { return fs.readFileSync(path.join(dir, c.dir, 'scene.html'), 'utf8'); } catch { return ''; } })() : '';
|
|
163
|
+
return {
|
|
164
|
+
dir: c.dir, id: c.id, name: c.name ?? c.id, what: c.what ?? '', seconds: c.seconds ?? null, join: c.join ?? chain.join ?? 'whip',
|
|
165
|
+
texts: sceneTexts(html), clip: c.id ? `${SITE}/previews/${c.id}.webm` : null,
|
|
166
|
+
draft: c.dir ? fileUrl(cwd, path.join(dir, 'drafts', `${c.dir}.mp4`)) : null,
|
|
167
|
+
...(p.parts?.[c.dir] ?? { status: 'queued' }),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
const renders = path.join(dir, 'renders'), name = path.basename(dir), film = filmState(cwd, dir);
|
|
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 })) : [];
|
|
172
|
+
return {
|
|
173
|
+
...p, boards: p.boards ?? [], dir: path.relative(cwd, dir), format: chain.format ?? null, join: chain.join ?? 'whip', joins: Object.keys(JOINS),
|
|
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 },
|
|
187
|
+
look: {
|
|
188
|
+
colors: Object.entries(tokens.colors ?? {}).map(([k, v]) => [k, v?.hex ?? v]),
|
|
189
|
+
fonts: Object.entries(tokens.fonts ?? {}).map(([k, v]) => [k, v?.family ?? v, v?.css ?? null]),
|
|
190
|
+
backgrounds: (set.entries ?? []).map((e) => e.id),
|
|
191
|
+
moves: fs.existsSync(movesDir) ? fs.readdirSync(movesDir).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, '')) : [],
|
|
192
|
+
},
|
|
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,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
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
|
+
|
|
217
|
+
// ── actions from the page ───────────────────────────────────────────────────────────────────
|
|
218
|
+
function act(cwd, piece, a, queue) {
|
|
219
|
+
const dir = pieceDir(piece, cwd), chainFile = path.join(dir, 'chain.json');
|
|
220
|
+
const chain = () => readJson(chainFile, { parts: [] }), saveChain = (c) => fs.writeFileSync(chainFile, JSON.stringify(c, null, 2) + '\n');
|
|
221
|
+
const text = (v) => String(v ?? '').trim();
|
|
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; };
|
|
226
|
+
updatePiece(dir, (p) => {
|
|
227
|
+
switch (a.type) {
|
|
228
|
+
case 'approve': { // a stage
|
|
229
|
+
if (!STAGES.includes(a.stage)) throw new Error('bad stage');
|
|
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
|
+
}
|
|
244
|
+
p.stages[a.stage] = { status: 'approved', at: new Date().toISOString() };
|
|
245
|
+
const next = STAGES[STAGES.indexOf(a.stage) + 1];
|
|
246
|
+
if (next) { p.stage = next; if (p.stages[next].status === 'pending') p.stages[next] = { status: 'working', since: new Date().toISOString() }; }
|
|
247
|
+
addEvent(p, { kind: 'approve', stage: a.stage });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'changes': { // a stage, or a part when a.part
|
|
251
|
+
const t = text(a.text); if (!t) throw new Error('say what to change');
|
|
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 }); }
|
|
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 }); }
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'reopen': { p.stages[a.stage] = { status: 'review' }; addEvent(p, { kind: 'reopen', stage: a.stage }); break; }
|
|
257
|
+
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
|
+
case 'brief': {
|
|
259
|
+
if (!BRIEF_KEYS.includes(a.key)) throw new Error('bad brief key');
|
|
260
|
+
const t = text(a.value); if (t === p.brief[a.key]) break;
|
|
261
|
+
p.brief[a.key] = t;
|
|
262
|
+
// an approved brief that changes comes back for the user to confirm
|
|
263
|
+
if (p.stages.brief.status === 'approved') p.stages.brief = { status: 'review', note: 'edited by hand' };
|
|
264
|
+
addEvent(p, { kind: 'edit', stage: 'brief', key: a.key, text: t });
|
|
265
|
+
break;
|
|
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
|
+
}
|
|
294
|
+
case 'what': { // a part's description: a note for the agent, kept in chain.json
|
|
295
|
+
const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
|
|
296
|
+
part.what = text(a.value); saveChain(c);
|
|
297
|
+
addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: 'what', text: part.what });
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case 'text': { // a word on screen: straight into the scene's CONFIG, then a fresh draft
|
|
301
|
+
const f = path.join(dir, a.part, 'scene.html');
|
|
302
|
+
fs.writeFileSync(f, setSceneText(fs.readFileSync(f, 'utf8'), a.key, text(a.value)));
|
|
303
|
+
redo(p, a.part);
|
|
304
|
+
addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: a.key, text: text(a.value), rerendered: true });
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'seconds':
|
|
308
|
+
case 'join': {
|
|
309
|
+
const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
|
|
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
|
+
}
|
|
315
|
+
else { if (!(a.value in JOINS)) throw new Error('bad join'); part.join = a.value; }
|
|
316
|
+
saveChain(c);
|
|
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' });
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'order': {
|
|
321
|
+
const c = chain(), by = new Map(c.parts.map((x) => [x.dir, x]));
|
|
322
|
+
if (a.order.length !== c.parts.length || a.order.some((d) => !by.has(d))) throw new Error('bad order');
|
|
323
|
+
c.parts = a.order.map((d) => by.get(d)); saveChain(c);
|
|
324
|
+
addEvent(p, { kind: 'order', stage: 'parts', text: a.order.join(',') });
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
default: throw new Error(`unknown action ${a.type}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
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);
|
|
345
|
+
const p = readPiece(dir);
|
|
346
|
+
return { ok: true, line: eventLine(p, p.events.at(-1)) };
|
|
347
|
+
}
|
|
348
|
+
|
|
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
|
+
*/
|
|
390
|
+
function renderQueue(cwd, log) {
|
|
391
|
+
const parts = [], joins = new Set();
|
|
392
|
+
let busy = false;
|
|
393
|
+
const next = async () => {
|
|
394
|
+
if (busy || (!parts.length && !joins.size)) return;
|
|
395
|
+
busy = true;
|
|
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
|
+
}
|
|
424
|
+
busy = false; next();
|
|
425
|
+
};
|
|
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
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function sendFile(req, res, f) {
|
|
433
|
+
const st = fs.statSync(f), type = TYPES[path.extname(f).toLowerCase()] ?? 'application/octet-stream';
|
|
434
|
+
const range = req.headers.range?.match(/bytes=(\d*)-(\d*)/);
|
|
435
|
+
if (range) {
|
|
436
|
+
const start = range[1] ? Number(range[1]) : 0, end = range[2] ? Number(range[2]) : st.size - 1;
|
|
437
|
+
res.writeHead(206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-cache' });
|
|
438
|
+
fs.createReadStream(f, { start, end }).pipe(res);
|
|
439
|
+
} else {
|
|
440
|
+
res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache' });
|
|
441
|
+
fs.createReadStream(f).pipe(res);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function startStudio({ cwd = process.cwd(), port = 4317, log = console.log } = {}) {
|
|
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}`));
|
|
455
|
+
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
456
|
+
const server = http.createServer(async (req, res) => {
|
|
457
|
+
const u = new URL(req.url, 'http://x');
|
|
458
|
+
try {
|
|
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
|
+
}
|
|
466
|
+
if (u.pathname === '/api/pieces') return json(res, 200, listPieces(cwd));
|
|
467
|
+
if (u.pathname === '/api/info') return json(res, 200, { app: 'paper-motion-studio', cwd: path.resolve(cwd), pid: process.pid });
|
|
468
|
+
if (u.pathname === '/api/piece') {
|
|
469
|
+
const piece = u.searchParams.get('p');
|
|
470
|
+
if (req.method === 'POST') {
|
|
471
|
+
let body = ''; for await (const c of req) body += c;
|
|
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] }); }
|
|
480
|
+
}
|
|
481
|
+
await refreshBoards(cwd, piece);
|
|
482
|
+
const v = view(cwd, piece);
|
|
483
|
+
return v ? json(res, 200, v) : json(res, 404, { error: `no piece "${piece}"` });
|
|
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
|
+
}
|
|
513
|
+
if (u.pathname.startsWith('/files/')) {
|
|
514
|
+
const f = path.resolve(cwd, decodeURIComponent(u.pathname.slice(7)));
|
|
515
|
+
if (!f.startsWith(path.resolve(cwd) + path.sep) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); }
|
|
516
|
+
return sendFile(req, res, f);
|
|
517
|
+
}
|
|
518
|
+
res.writeHead(404); res.end();
|
|
519
|
+
} catch (e) { json(res, 500, { error: e.message }); }
|
|
520
|
+
});
|
|
521
|
+
return new Promise((resolve, reject) => {
|
|
522
|
+
server.on('error', reject);
|
|
523
|
+
server.listen(port, '127.0.0.1', () => resolve({ server, port: server.address().port, close: () => new Promise((r) => server.close(r)) }));
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── one studio per project, in the background ─────────────────────────────────────────────────
|
|
528
|
+
const FIRST_PORT = 4317, LAST_PORT = 4336;
|
|
529
|
+
async function probe(port) {
|
|
530
|
+
try {
|
|
531
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/info`, { signal: AbortSignal.timeout(800) });
|
|
532
|
+
return r.ok ? await r.json() : { busy: true };
|
|
533
|
+
} catch (e) { return e?.cause?.code === 'ECONNREFUSED' ? null : { busy: true }; }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The studio serving this project, if one is running: { port, url }. */
|
|
537
|
+
export async function findStudio(cwd = process.cwd()) {
|
|
538
|
+
for (let port = FIRST_PORT; port <= LAST_PORT; port++) {
|
|
539
|
+
const info = await probe(port);
|
|
540
|
+
if (info?.app === 'paper-motion-studio' && info.cwd === path.resolve(cwd)) return { port, url: `http://127.0.0.1:${port}/`, pid: info.pid };
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Makes sure this project has a studio running: reuses it, or starts one detached (it outlives the command, so the
|
|
547
|
+
* user's page keeps working between the agent's steps) on the first free port. `pamo studio --stop` ends it.
|
|
548
|
+
*/
|
|
549
|
+
export async function ensureStudio(cwd = process.cwd(), { bin } = {}) {
|
|
550
|
+
const found = await findStudio(cwd);
|
|
551
|
+
if (found) return { ...found, started: false };
|
|
552
|
+
let port = FIRST_PORT;
|
|
553
|
+
while (port <= LAST_PORT && (await probe(port))) port++;
|
|
554
|
+
if (port > LAST_PORT) throw new Error(`no free port between ${FIRST_PORT} and ${LAST_PORT} for the studio`);
|
|
555
|
+
const { spawn } = await import('node:child_process');
|
|
556
|
+
const child = spawn(process.execPath, [bin, 'studio', '--port', String(port)], { cwd, detached: true, stdio: 'ignore' });
|
|
557
|
+
child.unref();
|
|
558
|
+
for (let i = 0; i < 40; i++) {
|
|
559
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
560
|
+
const info = await probe(port);
|
|
561
|
+
if (info?.app === 'paper-motion-studio') return { port, url: `http://127.0.0.1:${port}/`, pid: info.pid, started: true };
|
|
562
|
+
}
|
|
563
|
+
throw new Error(`the studio didn't start on port ${port} (run: npx paper-motion studio --port ${port})`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Opens a URL in the user's browser (macOS, Windows, Linux); quietly does nothing where it can't. */
|
|
567
|
+
export async function openInBrowser(url) {
|
|
568
|
+
if (process.env.CI || process.env.PAPER_MOTION_NO_OPEN) return false;
|
|
569
|
+
const { spawn } = await import('node:child_process');
|
|
570
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
571
|
+
try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref(); return true; } catch { return false; }
|
|
572
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paper-motion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"bin/",
|
|
33
33
|
"lib/",
|
|
34
34
|
"runtime/",
|
|
35
|
+
"studio/",
|
|
35
36
|
"templates/",
|
|
36
37
|
"examples/",
|
|
37
38
|
"!examples/**/renders/",
|
package/runtime/brand.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function loadBrand(root = '/brand') {
|
|
|
71
71
|
return layer;
|
|
72
72
|
},
|
|
73
73
|
/** Schedule the brand's sound for a gesture (transition, land, text) at time t. */
|
|
74
|
-
cue: (s, gesture, t, extra = {}) => { const c = pal[gesture]; if (c) s.sfx(t, c.type, { ...c, ...extra }); },
|
|
74
|
+
cue: (s, gesture, t, extra = {}) => { (s.marks ??= []).push({ gesture, t }); const c = pal[gesture]; if (c) s.sfx(t, c.type, { ...c, ...extra }); },
|
|
75
75
|
/** The brand's keystroke options for keySounds(): { bank, style, gain }. */
|
|
76
76
|
keys: { ...(sound?.keys?.bank ? { bank: sound.keys.bank } : {}), ...(sound?.keys?.style ? { style: sound.keys.style } : {}), gain: sound?.keys?.gain ?? 0.16 },
|
|
77
77
|
/** Apply the brand's master sound settings (and its bed, if any) to a scene. */
|
package/runtime/index.js
CHANGED
|
@@ -124,6 +124,7 @@ class Scene {
|
|
|
124
124
|
seek: async (t) => { await this.seek(t); await nextFrame(); await nextFrame(); return true; },
|
|
125
125
|
cues: this.cues,
|
|
126
126
|
audio: this.audio,
|
|
127
|
+
marks: this.marks ?? [], // gestures a scene declared (B.cue): where its hits land, for cutting it short
|
|
127
128
|
};
|
|
128
129
|
document.documentElement.dataset.pmReady = '1';
|
|
129
130
|
if (!RENDER) mountPlayer(this, startT);
|