paper-motion 0.2.6 → 0.3.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/lib/piece.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // A piece's progress: pieces/<piece>/piece.json, shared by the agent (CLI) and the user (pamo studio).
2
+ //
3
+ // stage the stage the piece is at: brief → look → structure → parts → assembly → final
4
+ // stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
5
+ // brief what, who, format, tone, action: written by the agent, editable by the user
6
+ // parts per part (by its folder in chain.json): status (queued · rendering · review · changes · approved), note
7
+ // events what the user did in the studio, in order; `seen` is the last one the agent has read
8
+ //
9
+ // chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { pieceDir } from './chain.mjs';
13
+
14
+ export const STAGES = ['brief', 'look', 'structure', 'parts', 'assembly', 'final'];
15
+ export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
16
+ export const BRIEF_KEYS = ['what', 'who', 'format', 'tone', 'action'];
17
+
18
+ const now = () => new Date().toISOString();
19
+ const file = (dir) => path.join(dir, 'piece.json');
20
+
21
+ export function readPiece(dir) {
22
+ try { return JSON.parse(fs.readFileSync(file(dir), 'utf8')); } catch { return null; }
23
+ }
24
+
25
+ /** Writes atomically (the studio and the CLI both write it). */
26
+ export function writePiece(dir, p) {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ const tmp = `${file(dir)}.${process.pid}.tmp`;
29
+ fs.writeFileSync(tmp, JSON.stringify(p, null, 2) + '\n');
30
+ fs.renameSync(tmp, file(dir));
31
+ return p;
32
+ }
33
+
34
+ /** Read, change, write: `fn` mutates the piece (re-read right before writing, so concurrent writers rarely clash). */
35
+ export function updatePiece(dir, fn) {
36
+ const p = readPiece(dir);
37
+ if (!p) throw new Error(`no piece.json in ${dir} (pamo piece init first)`);
38
+ fn(p);
39
+ p.updated = now();
40
+ return writePiece(dir, p);
41
+ }
42
+
43
+ export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force = false }) {
44
+ const dir = pieceDir(piece, cwd), old = readPiece(dir);
45
+ if (old && !force) {
46
+ // an existing piece: fill in what was given, keep the rest
47
+ return updatePiece(dir, (p) => { if (title) p.title = title; for (const k of BRIEF_KEYS) if (brief[k]) p.brief[k] = brief[k]; });
48
+ }
49
+ const tokens = (() => { try { return JSON.parse(fs.readFileSync(path.join(cwd, 'brand/tokens.json'), 'utf8')); } catch { return {}; } })();
50
+ return writePiece(dir, {
51
+ v: 1, piece: path.basename(dir), title: title ?? path.basename(dir), brand: tokens.brand ?? null,
52
+ stage: 'brief',
53
+ stages: Object.fromEntries(STAGES.map((s, i) => [s, { status: i ? 'pending' : 'working' }])),
54
+ brief: Object.fromEntries(BRIEF_KEYS.map((k) => [k, brief[k] ?? ''])),
55
+ parts: {}, events: [], seen: 0, created: now(), updated: now(),
56
+ });
57
+ }
58
+
59
+ /** The line the agent reads for an event (same family as the board reply). */
60
+ export function eventLine(p, e) {
61
+ const who = e.part ? `part ${e.part}` : e.stage;
62
+ const what = e.kind === 'approve' ? 'approved'
63
+ : e.kind === 'changes' ? `changes: "${e.text}"`
64
+ : e.kind === 'order' ? `order ${e.text}`
65
+ : e.kind === 'reopen' ? 'reopened'
66
+ : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
67
+ const tail = e.rejoin ? ' · join again: pamo chain --draft' : '';
68
+ return `paper-motion: piece ${p.piece} · ${e.kind === 'order' ? '' : `${who} `}${what}${tail}`.replace(/ {2,}/g, ' ');
69
+ }
70
+
71
+ /** Appends a user event (from the studio). */
72
+ export function addEvent(p, e) {
73
+ const n = (p.events.at(-1)?.n ?? 0) + 1;
74
+ const ev = { n, at: now(), ...e };
75
+ p.events.push(ev);
76
+ return ev;
77
+ }
78
+
79
+ /** The agent hands a stage to the user: it becomes the current stage, in review. */
80
+ export function toReview(dir, stage) {
81
+ if (!STAGES.includes(stage)) throw new Error(`stage "${stage}": use ${STAGES.join(', ')}`);
82
+ return updatePiece(dir, (p) => {
83
+ p.stage = stage;
84
+ p.stages[stage] = { status: 'review', since: now() };
85
+ for (const s of STAGES.slice(0, STAGES.indexOf(stage))) if (p.stages[s].status !== 'approved') p.stages[s] = { ...p.stages[s], status: 'approved' };
86
+ });
87
+ }
88
+
89
+ /** Events the agent hasn't read; marks them read when `mark`. */
90
+ export function unseen(dir, { mark = true } = {}) {
91
+ let out = [];
92
+ const p = updatePiece(dir, (q) => { out = q.events.filter((e) => e.n > (q.seen ?? 0)); if (mark && out.length) q.seen = out.at(-1).n; });
93
+ return { piece: p, events: out };
94
+ }
95
+
96
+ // an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
97
+ const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin;
98
+
99
+ /**
100
+ * Waits until the user decides on `stage` in the studio (approves it, or asks for changes), then returns every
101
+ * event since the agent last looked, as lines. Edits the studio already applied (texts, seconds, order) come along.
102
+ */
103
+ export async function awaitDecision(dir, stage, { timeout = 30 * 60 * 1000, every = 1000, signal } = {}) {
104
+ const t0 = Date.now();
105
+ for (;;) {
106
+ const p = readPiece(dir);
107
+ const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
108
+ if (fresh.some((e) => decisive(e, stage))) {
109
+ const r = unseen(dir);
110
+ return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) };
111
+ }
112
+ if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: fresh, lines: fresh.map((e) => eventLine(p, e)) };
113
+ await new Promise((res) => setTimeout(res, every));
114
+ }
115
+ }
116
+
117
+ // ── the words on screen: a scene's top-level string CONFIG entries ──────────────────────────
118
+ const CONFIG_STRING = /^ {2}(\w+): '((?:[^'\\]|\\.)*)',?\s*(\/\/.*)?$/;
119
+ const SKIP = new Set(['background', 'image', 'svg', 'font', 'fontFamily']);
120
+
121
+ /** The editable texts of a scene: top-level CONFIG strings (not ids or file paths). */
122
+ export function sceneTexts(html) {
123
+ const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
124
+ if (start < 0 || end < 0) return {};
125
+ const out = {};
126
+ for (const line of html.slice(start, end).split('\n')) {
127
+ const m = line.match(CONFIG_STRING);
128
+ if (m && !SKIP.has(m[1]) && !/\.(png|jpe?g|webp|svg|mp4|webm)$/.test(m[2])) out[m[1]] = m[2].replace(/\\(.)/g, '$1');
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Rewrites one top-level CONFIG string in a scene. */
134
+ export function setSceneText(html, key, value) {
135
+ const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
136
+ const head = html.slice(0, start), body = html.slice(start, end), tail = html.slice(end);
137
+ const lit = `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
138
+ let hit = false;
139
+ const next = body.split('\n').map((line) => {
140
+ const m = line.match(CONFIG_STRING);
141
+ if (!m || m[1] !== key) return line;
142
+ hit = true;
143
+ return ` ${key}: ${lit},${m[3] ? ` ${m[3]}` : ''}`;
144
+ }).join('\n');
145
+ if (!hit) throw new Error(`CONFIG has no text "${key}"`);
146
+ return head + next + tail;
147
+ }
package/lib/registry.mjs CHANGED
@@ -12,8 +12,8 @@ const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
12
  const REPO = path.resolve(ENGINE, '..', '..');
13
13
  // until the paper-motion.app domain is connected, the live site is the vercel URL
14
14
  export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
15
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
16
- const TYPE_TITLES = { component: 'Component transitions', transition: 'Transitions', text: 'Text effects', camera: 'Camera and energy', background: 'Backgrounds', palette: 'Palettes', 'font-pair': 'Font pairs', recipe: 'Recipes', shot: 'Shots' };
15
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot', 'block'];
16
+ const TYPE_TITLES = { component: 'Component transitions', transition: 'Transitions', text: 'Text effects', camera: 'Camera and energy', background: 'Backgrounds', palette: 'Palettes', 'font-pair': 'Font pairs', recipe: 'Recipes', shot: 'Shots', block: 'Blocks' };
17
17
 
18
18
  /** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
19
19
  export function resolveRegistry(given) {
@@ -114,16 +114,27 @@ export function llmsTxt(items, site, { full = false } = {}) {
114
114
  skills and MCP servers when a session starts, so after installing the skills **read
115
115
  \`.claude/skills/make-video/SKILL.md\` now** (or \`.agents/skills/make-video/SKILL.md\`) and follow it in this
116
116
  session; tell the user that the MCP at ${site}/mcp, if they want it, is added before opening a session.
117
- 2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
117
+ 2. **Open the studio: it is where the user decides.** A video is made in stages the user watches and approves in
118
+ a local page: brief, look, structure, parts, assembly, final. Start the piece right after setup:
119
+ \`npx paper-motion piece init <piece> --title "…"\` (it starts the project's studio in the background). Then,
120
+ at the end of each stage, \`npx paper-motion piece review <piece> <stage>\`: the first time it opens the studio in
121
+ the user's browser; it waits for their decision and prints what they did, one \`paper-motion: piece …\` line each
122
+ (an approval, a change to make, or an edit they already made by hand: the brief, words on screen, lengths, joins,
123
+ order; build on those, never undo them). Quick renders for review: \`chain <piece> --drafts\` (each part on its
124
+ own) and \`chain <piece> --draft\` (joined). Only when the user can't open a browser, run the same stages in the
125
+ chat.
126
+ 3. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
118
127
  line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
119
128
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
120
- Mirror it back before proposing anything. **You never choose a look for the user.** These decisions always go
129
+ Mirror it back, write it into the brief (\`piece init --what … --who … --format … --tone … --action …\`) and
130
+ \`piece review <piece> brief\`. **You never choose a look for the user.** These decisions always go
121
131
  through a board, in this order, one board each: palette · font pair · background(s) · moves by role (how things
122
132
  enter, how one thing becomes the next, how text arrives; let them pick several with \`choose\`) · recipe or shots
123
133
  (shots are 3–6 s camera gestures: a close-up, a pass over a component, a cursor that clicks; they chain before a
124
- recipe or stand alone). Skip a
134
+ recipe or stand alone) · blocks for the moments inside it (ready-made 4–8 s pieces: annotations on a screen, a
135
+ form, a terminal, code typing, a chart, captions, a logo sting; each recomposes for 16:9, 9:16 and 1:1). Skip a
125
136
  board only when the brand folder already settles that decision, and say so.
126
- 3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
137
+ 4. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
127
138
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
128
139
  (format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
129
140
  with the board in the URL fragment otherwise. The user opens it, sees the options live in their look, may tune one,
@@ -133,16 +144,23 @@ export function llmsTxt(items, site, { full = false } = {}) {
133
144
  (\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
134
145
  reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
135
146
  they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
136
- 4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
147
+ 5. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
137
148
  \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
138
149
  fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
139
150
  whose prompt carries the whole \`pamo add\` command. \`add\` never overwrites a value already in the project: when
140
151
  the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
141
152
  \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
142
153
  from \`tokens.json\` \`brand\`: write those from the interview before rendering.
143
- 5. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
154
+ 6. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
144
155
  a recipe's own moves are placeholders to swap for theirs. Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
145
156
  \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
157
+ When the user chose a structure whose beats name blocks or shots (or several blocks in order), chain them instead:
158
+ \`npx paper-motion chain init <piece> --board <id> --option D\` installs each beat's block under
159
+ \`pieces/<piece>/<nn>-<id>/\` and writes \`chain.json\` (seconds per part; \`join\`: whip, cut, dissolve or dip);
160
+ write each part's \`CONFIG\` from its beat, then \`npx paper-motion chain <piece> --motion-blur 5\` renders every
161
+ part in the brand's look and joins them into one video. It warns when a part is cut before its land.
162
+ In the studio: review \`look\` after installing the choices, \`structure\` after \`chain init\`, \`parts\` after
163
+ \`chain --drafts\`, \`assembly\` after \`chain --draft\`, \`final\` after the full render.
146
164
 
147
165
  ## Choosing well: the metadata
148
166
 
@@ -157,10 +175,11 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
157
175
  reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
158
176
  installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
159
177
  texture first; the color comes from the palette.
160
- - Font pairs: \`voice\` (serif, sans, display, mono). Recipes and shots: \`duration\`, \`formats\` and \`beats\` (the structure as a
178
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes, shots and blocks: \`duration\`, \`formats\` and \`beats\` (the structure as a
161
179
  timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
162
180
  name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
163
- format below), which you then build from scratch. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
181
+ format below), which you then build from scratch. Give each of its beats the \`id\` of the block or shot that shows
182
+ it: the board then plays those clips in order, so the user watches the structure instead of reading it. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
164
183
  order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
165
184
  reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
166
185
  the reference for its block.
@@ -202,9 +221,9 @@ A recipe board may hold one option that is a structure of your own for this brie
202
221
 
203
222
  \`\`\`json
204
223
  { "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
205
- { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
206
- { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
207
- { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action" } ] } }
224
+ { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field", "id": "word-swap" },
225
+ { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product", "id": "browser-frame" },
226
+ { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action", "id": "svg-logo" } ] } }
208
227
  \`\`\`
209
228
 
210
229
  Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
package/lib/studio.mjs ADDED
@@ -0,0 +1,258 @@
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
+ // GET /files/<path> files under the project (renders, drafts), with byte ranges for video
9
+ //
10
+ // Edits that need no judgment are applied here: a text goes into the part's CONFIG and the part re-renders as a
11
+ // draft; lengths, joins and order go into chain.json. Everything the user does is also an event in piece.json,
12
+ // which `pamo piece review` hands to the agent.
13
+ import fs from 'node:fs';
14
+ import http from 'node:http';
15
+ import path from 'node:path';
16
+ 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';
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+ const PAGE = path.join(HERE, '..', 'studio', 'index.html');
22
+ 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' };
24
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
25
+
26
+ /** Pieces in the project that the studio can show. */
27
+ export function listPieces(cwd) {
28
+ const root = path.join(cwd, 'pieces');
29
+ if (!fs.existsSync(root)) return [];
30
+ return fs.readdirSync(root).filter((d) => fs.existsSync(path.join(root, d, 'piece.json'))).map((d) => {
31
+ const p = readJson(path.join(root, d, 'piece.json'), {});
32
+ return { piece: d, title: p.title, stage: p.stage, status: p.stages?.[p.stage]?.status, updated: p.updated };
33
+ }).sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
34
+ }
35
+
36
+ const fileUrl = (cwd, f) => {
37
+ if (!fs.existsSync(f)) return null;
38
+ return `/files/${path.relative(cwd, f).split(path.sep).map(encodeURIComponent).join('/')}?v=${Math.round(fs.statSync(f).mtimeMs)}`;
39
+ };
40
+
41
+ /** What the page shows for one piece. */
42
+ export function view(cwd, piece) {
43
+ const dir = pieceDir(piece, cwd), p = readPiece(dir);
44
+ if (!p) return null;
45
+ const chain = readJson(path.join(dir, 'chain.json'), { parts: [] });
46
+ const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {});
47
+ const movesDir = path.join(cwd, 'moves');
48
+ const parts = (chain.parts ?? []).map((c) => {
49
+ const html = c.dir ? (() => { try { return fs.readFileSync(path.join(dir, c.dir, 'scene.html'), 'utf8'); } catch { return ''; } })() : '';
50
+ return {
51
+ dir: c.dir, id: c.id, name: c.name ?? c.id, what: c.what ?? '', seconds: c.seconds ?? null, join: c.join ?? chain.join ?? 'whip',
52
+ texts: sceneTexts(html), clip: c.id ? `${SITE}/previews/${c.id}.webm` : null,
53
+ draft: c.dir ? fileUrl(cwd, path.join(dir, 'drafts', `${c.dir}.mp4`)) : null,
54
+ ...(p.parts?.[c.dir] ?? { status: 'queued' }),
55
+ };
56
+ });
57
+ const renders = path.join(dir, 'renders'), name = path.basename(dir);
58
+ 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
+ return {
60
+ ...p, dir: path.relative(cwd, dir), format: chain.format ?? null, join: chain.join ?? 'whip', joins: Object.keys(JOINS),
61
+ parts,
62
+ look: {
63
+ 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]),
65
+ backgrounds: (set.entries ?? []).map((e) => e.id),
66
+ moves: fs.existsSync(movesDir) ? fs.readdirSync(movesDir).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, '')) : [],
67
+ },
68
+ media: { assembly: fileUrl(cwd, path.join(renders, `${name}-draft.mp4`)), final: fileUrl(cwd, path.join(renders, `${name}.mp4`)), outputs },
69
+ };
70
+ }
71
+
72
+ // ── actions from the page ───────────────────────────────────────────────────────────────────
73
+ function act(cwd, piece, a, queue) {
74
+ const dir = pieceDir(piece, cwd), chainFile = path.join(dir, 'chain.json');
75
+ const chain = () => readJson(chainFile, { parts: [] }), saveChain = (c) => fs.writeFileSync(chainFile, JSON.stringify(c, null, 2) + '\n');
76
+ const text = (v) => String(v ?? '').trim();
77
+ let rerender = null;
78
+ updatePiece(dir, (p) => {
79
+ switch (a.type) {
80
+ case 'approve': { // a stage
81
+ if (!STAGES.includes(a.stage)) throw new Error('bad stage');
82
+ if (a.stage === 'parts' && Object.values(p.parts).some((x) => x.status !== 'approved')) throw new Error('approve every part first');
83
+ p.stages[a.stage] = { status: 'approved', at: new Date().toISOString() };
84
+ 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' }; }
86
+ addEvent(p, { kind: 'approve', stage: a.stage });
87
+ break;
88
+ }
89
+ case 'changes': { // a stage, or a part when a.part
90
+ const t = text(a.text); if (!t) throw new Error('say what to change');
91
+ 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 }); }
93
+ break;
94
+ }
95
+ case 'reopen': { p.stages[a.stage] = { status: 'review' }; addEvent(p, { kind: 'reopen', stage: a.stage }); break; }
96
+ 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; }
97
+ case 'brief': {
98
+ if (!BRIEF_KEYS.includes(a.key)) throw new Error('bad brief key');
99
+ const t = text(a.value); if (t === p.brief[a.key]) break;
100
+ p.brief[a.key] = t;
101
+ // an approved brief that changes comes back for the user to confirm
102
+ if (p.stages.brief.status === 'approved') p.stages.brief = { status: 'review', note: 'edited by hand' };
103
+ addEvent(p, { kind: 'edit', stage: 'brief', key: a.key, text: t });
104
+ break;
105
+ }
106
+ case 'what': { // a part's description: a note for the agent, kept in chain.json
107
+ const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
108
+ part.what = text(a.value); saveChain(c);
109
+ addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: 'what', text: part.what });
110
+ break;
111
+ }
112
+ case 'text': { // a word on screen: straight into the scene's CONFIG, then a fresh draft
113
+ const f = path.join(dir, a.part, 'scene.html');
114
+ fs.writeFileSync(f, setSceneText(fs.readFileSync(f, 'utf8'), a.key, text(a.value)));
115
+ p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'queued' };
116
+ addEvent(p, { kind: 'edit', stage: 'parts', part: a.part, key: a.key, text: text(a.value), rerendered: true });
117
+ rerender = a.part;
118
+ break;
119
+ }
120
+ case 'seconds':
121
+ case 'join': {
122
+ const c = chain(), part = c.parts.find((x) => x.dir === a.part); if (!part) throw new Error('no such part');
123
+ if (a.type === 'seconds') { part.seconds = Math.max(0.5, Math.min(30, Number(a.value))); rerender = a.part; p.parts[a.part] = { ...(p.parts[a.part] ?? {}), status: 'queued' }; }
124
+ else { if (!(a.value in JOINS)) throw new Error('bad join'); part.join = a.value; }
125
+ saveChain(c);
126
+ 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' });
127
+ break;
128
+ }
129
+ case 'order': {
130
+ const c = chain(), by = new Map(c.parts.map((x) => [x.dir, x]));
131
+ if (a.order.length !== c.parts.length || a.order.some((d) => !by.has(d))) throw new Error('bad order');
132
+ c.parts = a.order.map((d) => by.get(d)); saveChain(c);
133
+ addEvent(p, { kind: 'order', stage: 'parts', text: a.order.join(',') });
134
+ break;
135
+ }
136
+ default: throw new Error(`unknown action ${a.type}`);
137
+ }
138
+ });
139
+ // in Assembly, a new order, length or join means the joined version is stale: hand it back to the agent
140
+ if (['order', 'seconds', 'join'].includes(a.type)) updatePiece(dir, (p) => {
141
+ if (p.stage !== 'assembly') return;
142
+ p.stages.assembly = { status: 'changes', note: 'edited by hand: join the parts again' };
143
+ p.events.at(-1).rejoin = true;
144
+ });
145
+ if (rerender) queue(piece, rerender);
146
+ const p = readPiece(dir);
147
+ return { ok: true, line: eventLine(p, p.events.at(-1)) };
148
+ }
149
+
150
+ /** One part at a time re-renders as a draft after an edit (the engine needs no agent for that). */
151
+ function renderQueue(cwd, log) {
152
+ const q = [];
153
+ let busy = false;
154
+ const next = async () => {
155
+ if (busy || !q.length) return;
156
+ busy = true;
157
+ const [piece, part] = q.shift();
158
+ try {
159
+ const { renderDrafts } = await import('./chain.mjs');
160
+ await renderDrafts({ cwd, piece, only: [part] });
161
+ log(`re-rendered ${piece}/${part}`);
162
+ } catch (e) { log(`✗ ${piece}/${part}: ${e.message}`); }
163
+ busy = false; next();
164
+ };
165
+ return (piece, part) => { if (!q.some(([a, b]) => a === piece && b === part)) q.push([piece, part]); next(); };
166
+ }
167
+
168
+ function sendFile(req, res, f) {
169
+ const st = fs.statSync(f), type = TYPES[path.extname(f).toLowerCase()] ?? 'application/octet-stream';
170
+ const range = req.headers.range?.match(/bytes=(\d*)-(\d*)/);
171
+ if (range) {
172
+ const start = range[1] ? Number(range[1]) : 0, end = range[2] ? Number(range[2]) : st.size - 1;
173
+ 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' });
174
+ fs.createReadStream(f, { start, end }).pipe(res);
175
+ } else {
176
+ res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache' });
177
+ fs.createReadStream(f).pipe(res);
178
+ }
179
+ }
180
+
181
+ export function startStudio({ cwd = process.cwd(), port = 4317, log = console.log } = {}) {
182
+ const queue = renderQueue(cwd, log);
183
+ const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
184
+ const server = http.createServer(async (req, res) => {
185
+ const u = new URL(req.url, 'http://x');
186
+ try {
187
+ if (u.pathname === '/' || u.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' }); return res.end(fs.readFileSync(PAGE)); }
188
+ if (u.pathname === '/api/pieces') return json(res, 200, listPieces(cwd));
189
+ if (u.pathname === '/api/info') return json(res, 200, { app: 'paper-motion-studio', cwd: path.resolve(cwd), pid: process.pid });
190
+ if (u.pathname === '/api/piece') {
191
+ const piece = u.searchParams.get('p');
192
+ if (req.method === 'POST') {
193
+ let body = ''; for await (const c of req) body += c;
194
+ try { return json(res, 200, act(cwd, piece, JSON.parse(body || '{}'), queue)); } catch (e) { return json(res, 400, { error: e.message }); }
195
+ }
196
+ const v = view(cwd, piece);
197
+ return v ? json(res, 200, v) : json(res, 404, { error: `no piece "${piece}"` });
198
+ }
199
+ if (u.pathname.startsWith('/files/')) {
200
+ const f = path.resolve(cwd, decodeURIComponent(u.pathname.slice(7)));
201
+ if (!f.startsWith(path.resolve(cwd) + path.sep) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); }
202
+ return sendFile(req, res, f);
203
+ }
204
+ res.writeHead(404); res.end();
205
+ } catch (e) { json(res, 500, { error: e.message }); }
206
+ });
207
+ return new Promise((resolve, reject) => {
208
+ server.on('error', reject);
209
+ server.listen(port, '127.0.0.1', () => resolve({ server, port: server.address().port, close: () => new Promise((r) => server.close(r)) }));
210
+ });
211
+ }
212
+
213
+ // ── one studio per project, in the background ─────────────────────────────────────────────────
214
+ const FIRST_PORT = 4317, LAST_PORT = 4336;
215
+ async function probe(port) {
216
+ try {
217
+ const r = await fetch(`http://127.0.0.1:${port}/api/info`, { signal: AbortSignal.timeout(800) });
218
+ return r.ok ? await r.json() : { busy: true };
219
+ } catch (e) { return e?.cause?.code === 'ECONNREFUSED' ? null : { busy: true }; }
220
+ }
221
+
222
+ /** The studio serving this project, if one is running: { port, url }. */
223
+ export async function findStudio(cwd = process.cwd()) {
224
+ for (let port = FIRST_PORT; port <= LAST_PORT; port++) {
225
+ const info = await probe(port);
226
+ if (info?.app === 'paper-motion-studio' && info.cwd === path.resolve(cwd)) return { port, url: `http://127.0.0.1:${port}/`, pid: info.pid };
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Makes sure this project has a studio running: reuses it, or starts one detached (it outlives the command, so the
233
+ * user's page keeps working between the agent's steps) on the first free port. `pamo studio --stop` ends it.
234
+ */
235
+ export async function ensureStudio(cwd = process.cwd(), { bin } = {}) {
236
+ const found = await findStudio(cwd);
237
+ if (found) return { ...found, started: false };
238
+ let port = FIRST_PORT;
239
+ while (port <= LAST_PORT && (await probe(port))) port++;
240
+ if (port > LAST_PORT) throw new Error(`no free port between ${FIRST_PORT} and ${LAST_PORT} for the studio`);
241
+ const { spawn } = await import('node:child_process');
242
+ const child = spawn(process.execPath, [bin, 'studio', '--port', String(port)], { cwd, detached: true, stdio: 'ignore' });
243
+ child.unref();
244
+ for (let i = 0; i < 40; i++) {
245
+ await new Promise((res) => setTimeout(res, 150));
246
+ const info = await probe(port);
247
+ if (info?.app === 'paper-motion-studio') return { port, url: `http://127.0.0.1:${port}/`, pid: info.pid, started: true };
248
+ }
249
+ throw new Error(`the studio didn't start on port ${port} (run: npx paper-motion studio --port ${port})`);
250
+ }
251
+
252
+ /** Opens a URL in the user's browser (macOS, Windows, Linux); quietly does nothing where it can't. */
253
+ export async function openInBrowser(url) {
254
+ if (process.env.CI || process.env.PAPER_MOTION_NO_OPEN) return false;
255
+ const { spawn } = await import('node:child_process');
256
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
257
+ try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref(); return true; } catch { return false; }
258
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.2.6",
3
+ "version": "0.3.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);