paper-motion 0.2.7 → 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/CHANGELOG.md +11 -0
- package/bin/pamo.mjs +123 -2
- package/lib/chain.mjs +298 -0
- package/lib/live-board.js +3 -1
- package/lib/piece.mjs +147 -0
- package/lib/registry.mjs +27 -9
- package/lib/studio.mjs +258 -0
- package/package.json +2 -1
- package/runtime/brand.js +1 -1
- package/runtime/index.js +1 -0
- package/studio/index.html +391 -0
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
|
@@ -114,17 +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. **
|
|
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
|
|
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
134
|
recipe or stand alone) · blocks for the moments inside it (ready-made 4–8 s pieces: annotations on a screen, a
|
|
125
135
|
form, a terminal, code typing, a chart, captions, a logo sting; each recomposes for 16:9, 9:16 and 1:1). Skip a
|
|
126
136
|
board only when the brand folder already settles that decision, and say so.
|
|
127
|
-
|
|
137
|
+
4. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
|
|
128
138
|
this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
|
|
129
139
|
(format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
|
|
130
140
|
with the board in the URL fragment otherwise. The user opens it, sees the options live in their look, may tune one,
|
|
@@ -134,16 +144,23 @@ export function llmsTxt(items, site, { full = false } = {}) {
|
|
|
134
144
|
(\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
|
|
135
145
|
reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
|
|
136
146
|
they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
|
|
137
|
-
|
|
147
|
+
5. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
|
|
138
148
|
\`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
|
|
139
149
|
fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
|
|
140
150
|
whose prompt carries the whole \`pamo add\` command. \`add\` never overwrites a value already in the project: when
|
|
141
151
|
the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
|
|
142
152
|
\`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
|
|
143
153
|
from \`tokens.json\` \`brand\`: write those from the interview before rendering.
|
|
144
|
-
|
|
154
|
+
6. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
|
|
145
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
|
|
146
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.
|
|
147
164
|
|
|
148
165
|
## Choosing well: the metadata
|
|
149
166
|
|
|
@@ -161,7 +178,8 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
161
178
|
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes, shots and blocks: \`duration\`, \`formats\` and \`beats\` (the structure as a
|
|
162
179
|
timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
|
|
163
180
|
name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
|
|
164
|
-
format below), which you then build from scratch. Give
|
|
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
|
|
165
183
|
order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
|
|
166
184
|
reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
|
|
167
185
|
the reference for its block.
|
|
@@ -203,9 +221,9 @@ A recipe board may hold one option that is a structure of your own for this brie
|
|
|
203
221
|
|
|
204
222
|
\`\`\`json
|
|
205
223
|
{ "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
|
|
206
|
-
{ "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
|
|
207
|
-
{ "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
|
|
208
|
-
{ "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" } ] } }
|
|
209
227
|
\`\`\`
|
|
210
228
|
|
|
211
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.
|
|
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);
|