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/piece.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
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 → inventory → video → final. The user sees three steps (What it says,
|
|
4
|
+
// Your video, Export); the brief is settled in the chat.
|
|
5
|
+
// stages each stage's status: pending · working (the agent's turn) · review (the user's turn) · changes · approved
|
|
6
|
+
// brief what, who, format, tone, action: written by the agent, editable by the user
|
|
7
|
+
// parts per part (by its folder in chain.json): status (queued · rendering · review · changes · approved), note
|
|
8
|
+
// events what the user did in the studio, in order; `seen` is the last one the agent has read
|
|
9
|
+
// ask a question the agent asked in the chat and is waiting on (the studio sends the user there)
|
|
10
|
+
// agent the app or page the agent runs in, so the studio can take the user back to it
|
|
11
|
+
//
|
|
12
|
+
// chain.json stays the source of what plays (order, seconds, joins); piece.json only tracks decisions.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { pieceDir } from './chain.mjs';
|
|
16
|
+
|
|
17
|
+
export const STAGES = ['brief', 'inventory', 'video', 'final'];
|
|
18
|
+
// the stages a piece had before Your video took them over: a piece at one of them is at `video` now
|
|
19
|
+
const OLD_VIDEO = ['look', 'structure', 'parts', 'assembly'];
|
|
20
|
+
export const STAGE_STATUS = ['pending', 'working', 'review', 'changes', 'approved'];
|
|
21
|
+
export const BRIEF_KEYS = ['what', 'who', 'format', 'tone', 'action'];
|
|
22
|
+
|
|
23
|
+
const now = () => new Date().toISOString();
|
|
24
|
+
const file = (dir) => path.join(dir, 'piece.json');
|
|
25
|
+
|
|
26
|
+
export function readPiece(dir) {
|
|
27
|
+
try { return upgrade(JSON.parse(fs.readFileSync(file(dir), 'utf8'))); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
// a piece started before a stage existed: that stage counts as done when the piece is already past it
|
|
30
|
+
function upgrade(p) {
|
|
31
|
+
if (!p.stages.video) {
|
|
32
|
+
const at = OLD_VIDEO.indexOf(p.stage), last = OLD_VIDEO.findLast((s) => p.stages[s]);
|
|
33
|
+
p.stages.video = at >= 0 ? { ...p.stages[p.stage] } : last && p.stages[last].status === 'approved' && STAGES.indexOf(p.stage) > 0 ? { status: 'approved' } : undefined;
|
|
34
|
+
if (at >= 0) p.stage = 'video';
|
|
35
|
+
for (const s of OLD_VIDEO) delete p.stages[s];
|
|
36
|
+
if (!p.stages.video) delete p.stages.video;
|
|
37
|
+
}
|
|
38
|
+
for (const s of STAGES) p.stages[s] ??= STAGES.indexOf(p.stage) > STAGES.indexOf(s) ? { status: 'approved', skipped: true } : { status: 'pending' };
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Writes atomically (the studio and the CLI both write it). */
|
|
43
|
+
export function writePiece(dir, p) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
const tmp = `${file(dir)}.${process.pid}.tmp`;
|
|
46
|
+
fs.writeFileSync(tmp, JSON.stringify(p, null, 2) + '\n');
|
|
47
|
+
fs.renameSync(tmp, file(dir));
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read, change, write: `fn` mutates the piece (re-read right before writing, so concurrent writers rarely clash). */
|
|
52
|
+
export function updatePiece(dir, fn) {
|
|
53
|
+
const p = readPiece(dir);
|
|
54
|
+
if (!p) throw new Error(`no piece.json in ${dir} (pamo piece init first)`);
|
|
55
|
+
fn(p);
|
|
56
|
+
p.updated = now();
|
|
57
|
+
return writePiece(dir, p);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function initPiece({ cwd = process.cwd(), piece, title, brief = {}, force = false }) {
|
|
61
|
+
const dir = pieceDir(piece, cwd), old = readPiece(dir);
|
|
62
|
+
if (old && !force) {
|
|
63
|
+
// an existing piece: fill in what was given, keep the rest
|
|
64
|
+
return updatePiece(dir, (p) => { if (title) p.title = title; for (const k of BRIEF_KEYS) if (brief[k]) p.brief[k] = brief[k]; });
|
|
65
|
+
}
|
|
66
|
+
const tokens = (() => { try { return JSON.parse(fs.readFileSync(path.join(cwd, 'brand/tokens.json'), 'utf8')); } catch { return {}; } })();
|
|
67
|
+
return writePiece(dir, {
|
|
68
|
+
v: 1, piece: path.basename(dir), title: title ?? path.basename(dir), brand: tokens.brand ?? null,
|
|
69
|
+
stage: 'brief',
|
|
70
|
+
stages: Object.fromEntries(STAGES.map((s, i) => [s, { status: i ? 'pending' : 'working' }])),
|
|
71
|
+
brief: Object.fromEntries(BRIEF_KEYS.map((k) => [k, brief[k] ?? ''])),
|
|
72
|
+
parts: {}, events: [], seen: 0, created: now(), updated: now(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The line the agent reads for an event (same family as the board reply). */
|
|
77
|
+
export function eventLine(p, e) {
|
|
78
|
+
const who = e.part ? `part ${e.part}` : e.stage;
|
|
79
|
+
const what = e.kind === 'approve' ? 'approved'
|
|
80
|
+
: e.kind === 'changes' ? `changes: "${e.text}"`
|
|
81
|
+
: e.kind === 'order' ? `order ${e.text}`
|
|
82
|
+
: e.kind === 'reopen' ? 'reopened'
|
|
83
|
+
: e.kind === 'failed' ? `could not join the parts: "${e.text}"`
|
|
84
|
+
: e.say ? e.say : `${e.key}="${e.text}"${e.rerendered ? ' (re-rendered)' : ''}`;
|
|
85
|
+
// the studio joins the draft again by itself after an edit by hand; the full-quality render stays the agent's
|
|
86
|
+
const tail = `${e.rejoined ? ' (the studio is joining the draft again)' : ''}${e.rejoin ? ' · join again: pamo chain --draft' : ''}${e.refinal ? ` · the final is out of date: render it again (pamo chain ${p.piece} --motion-blur 5), then review final` : ''}`;
|
|
87
|
+
return `paper-motion: piece ${p.piece} · ${e.kind === 'order' ? '' : `${who} `}${what}${tail}`.replace(/ {2,}/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Appends a user event (from the studio). */
|
|
91
|
+
export function addEvent(p, e) {
|
|
92
|
+
const n = (p.events.at(-1)?.n ?? 0) + 1;
|
|
93
|
+
const ev = { n, at: now(), ...e };
|
|
94
|
+
p.events.push(ev);
|
|
95
|
+
return ev;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The agent hands a stage to the user: it becomes the current stage, in review. */
|
|
99
|
+
export function toReview(dir, stage, { skip = false } = {}) {
|
|
100
|
+
if (!STAGES.includes(stage)) throw new Error(`stage "${stage}": use ${STAGES.join(', ')}`);
|
|
101
|
+
return updatePiece(dir, (p) => {
|
|
102
|
+
// nothing moves on until the user approves: an earlier stage they have not approved stops the hand-over,
|
|
103
|
+
// unless the agent says outright that it is skipping it (`skip`), which marks it approved
|
|
104
|
+
const open = earlierOpen(p, stage);
|
|
105
|
+
if (open.length && !skip) throw new Error(`${open.join(', ')} not approved yet: review ${open.length > 1 ? 'them' : 'it'} first (piece review ${p.piece} ${open[0]}), or pass --skip when the user agreed to move on without ${open.length > 1 ? 'them' : 'it'}`);
|
|
106
|
+
for (const s of open) p.stages[s] = { ...p.stages[s], status: 'approved' };
|
|
107
|
+
delete p.ask; // handing a stage over means the question in the chat is settled
|
|
108
|
+
p.stage = stage;
|
|
109
|
+
p.stages[stage] = { status: 'review', since: now() };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The stages before `stage` that the user has not approved. */
|
|
114
|
+
export const earlierOpen = (p, stage) => STAGES.slice(0, STAGES.indexOf(stage)).filter((s) => p.stages[s].status !== 'approved');
|
|
115
|
+
|
|
116
|
+
/** Events the agent hasn't read; marks them read when `mark`. */
|
|
117
|
+
export function unseen(dir, { mark = true } = {}) {
|
|
118
|
+
let out = [];
|
|
119
|
+
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; });
|
|
120
|
+
return { piece: p, events: out };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// an event that ends the agent's wait: a decision on the stage under review, or a change asked anywhere
|
|
124
|
+
const decisive = (e, stage) => (e.kind === 'approve' && e.stage === stage && !e.part) || e.kind === 'changes' || e.kind === 'reopen' || e.rejoin || e.refinal;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Waits until the user decides on `stage` in the studio (approves it, or asks for changes), then returns every
|
|
128
|
+
* event since the agent last looked, as lines. Edits the studio already applied (texts, seconds, order) come along.
|
|
129
|
+
*/
|
|
130
|
+
export async function awaitDecision(dir, stage, { timeout = 30 * 60 * 1000, every = 1000, signal } = {}) {
|
|
131
|
+
const t0 = Date.now();
|
|
132
|
+
let last = 0;
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
if (Date.now() - last > 3000) { beat(dir, { waiting: stage }); last = Date.now(); } // the studio shows "your agent is listening"
|
|
136
|
+
const p = readPiece(dir);
|
|
137
|
+
const fresh = p.events.filter((e) => e.n > (p.seen ?? 0));
|
|
138
|
+
if (fresh.some((e) => decisive(e, stage))) {
|
|
139
|
+
const r = unseen(dir);
|
|
140
|
+
return { done: true, piece: r.piece, events: r.events, lines: r.events.map((e) => eventLine(r.piece, e)) };
|
|
141
|
+
}
|
|
142
|
+
if (Date.now() - t0 > timeout || signal?.aborted) return { done: false, piece: p, events: fresh, lines: fresh.map((e) => eventLine(p, e)) };
|
|
143
|
+
await new Promise((res) => setTimeout(res, every));
|
|
144
|
+
}
|
|
145
|
+
} finally { unbeat(dir); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── the agent's side of the conversation: is it listening, what did it ask, where does it live ────────────────
|
|
149
|
+
// While a command waits for the user (piece review, board), it says so every few seconds in a small file beside
|
|
150
|
+
// piece.json (piece.json itself stays quiet). No fresh beat means nobody reads what the user does in the studio.
|
|
151
|
+
const beatFile = (dir) => path.join(dir, '.agent.json');
|
|
152
|
+
export function beat(dir, info = {}) { try { fs.writeFileSync(beatFile(dir), JSON.stringify({ at: Date.now(), pid: process.pid, ...info })); } catch { /* a read-only project: the studio just won't know */ } }
|
|
153
|
+
export function unbeat(dir) { try { if (JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')).pid === process.pid) fs.rmSync(beatFile(dir)); } catch { /* already gone */ } }
|
|
154
|
+
/** The command waiting on this piece right now, if any. */
|
|
155
|
+
export function listening(dir, within = 9000) { try { const a = JSON.parse(fs.readFileSync(beatFile(dir), 'utf8')); return Date.now() - a.at < within ? a : null; } catch { return null; } }
|
|
156
|
+
|
|
157
|
+
/** The agent asked the user something in the chat: the studio says so until the agent moves on. */
|
|
158
|
+
export function setAsk(dir, text) { return updatePiece(dir, (p) => { p.ask = { text: String(text ?? '').trim(), at: now() }; }); }
|
|
159
|
+
export function clearAsk(dir) { const p = readPiece(dir); return p?.ask ? updatePiece(dir, (q) => { delete q.ask; }) : p; }
|
|
160
|
+
/** Where the agent runs (see host.mjs), kept so the studio can take the user back there. */
|
|
161
|
+
export function setHost(dir, host) {
|
|
162
|
+
const p = readPiece(dir);
|
|
163
|
+
if (!p || !host || JSON.stringify(p.agent ?? null) === JSON.stringify(host)) return p;
|
|
164
|
+
return updatePiece(dir, (q) => { q.agent = host; });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the words on screen: a scene's top-level string CONFIG entries ──────────────────────────
|
|
168
|
+
const CONFIG_STRING = /^ {2}(\w+): '((?:[^'\\]|\\.)*)',?\s*(\/\/.*)?$/;
|
|
169
|
+
const SKIP = new Set(['background', 'image', 'svg', 'font', 'fontFamily']);
|
|
170
|
+
|
|
171
|
+
/** The editable texts of a scene: top-level CONFIG strings (not ids or file paths). */
|
|
172
|
+
export function sceneTexts(html) {
|
|
173
|
+
const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
|
|
174
|
+
if (start < 0 || end < 0) return {};
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const line of html.slice(start, end).split('\n')) {
|
|
177
|
+
const m = line.match(CONFIG_STRING);
|
|
178
|
+
if (m && !SKIP.has(m[1]) && !/\.(png|jpe?g|webp|svg|mp4|webm)$/.test(m[2])) out[m[1]] = m[2].replace(/\\(.)/g, '$1');
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Rewrites one top-level CONFIG string in a scene. */
|
|
184
|
+
export function setSceneText(html, key, value) {
|
|
185
|
+
const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
|
|
186
|
+
const head = html.slice(0, start), body = html.slice(start, end), tail = html.slice(end);
|
|
187
|
+
const lit = `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
188
|
+
let hit = false;
|
|
189
|
+
const next = body.split('\n').map((line) => {
|
|
190
|
+
const m = line.match(CONFIG_STRING);
|
|
191
|
+
if (!m || m[1] !== key) return line;
|
|
192
|
+
hit = true;
|
|
193
|
+
return ` ${key}: ${lit},${m[3] ? ` ${m[3]}` : ''}`;
|
|
194
|
+
}).join('\n');
|
|
195
|
+
if (!hit) throw new Error(`CONFIG has no text "${key}"`);
|
|
196
|
+
return head + next + tail;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── boards: the decisions taken on paper-motion.app, listed in the studio so the user finds them there too ──────
|
|
200
|
+
const BOARD_STAGE = () => 'video';
|
|
201
|
+
|
|
202
|
+
/** The piece a board belongs to: the one named, else the only one in progress before Parts. */
|
|
203
|
+
export function activePiece(cwd, name) {
|
|
204
|
+
if (name) { const dir = pieceDir(name, cwd); return readPiece(dir) ? dir : null; }
|
|
205
|
+
const root = path.join(cwd, 'pieces');
|
|
206
|
+
if (!fs.existsSync(root)) return null;
|
|
207
|
+
const live = fs.readdirSync(root).map((d) => path.join(root, d)).filter((d) => { const p = readPiece(d); return p && ['brief', 'inventory', 'video'].includes(p.stage); });
|
|
208
|
+
return live.length === 1 ? live[0] : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Records a board shared for this piece (shown in the studio's Look or Structure stage until chosen). */
|
|
212
|
+
export function attachBoard(dir, { url, id, title, kind }) {
|
|
213
|
+
return updatePiece(dir, (p) => {
|
|
214
|
+
p.boards ??= [];
|
|
215
|
+
if (id && p.boards.some((b) => b.id === id)) return;
|
|
216
|
+
p.boards.push({ url, id: id ?? null, title, kind, stage: BOARD_STAGE(p.stage), at: now(), status: 'waiting' });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Marks a board chosen with the reply line. */
|
|
221
|
+
export function markBoard(dir, id, line) {
|
|
222
|
+
return updatePiece(dir, (p) => { const b = (p.boards ?? []).find((x) => x.id === id); if (b) { b.status = 'chosen'; b.line = line; } });
|
|
223
|
+
}
|
package/lib/registry.mjs
CHANGED
|
@@ -110,21 +110,31 @@ export function llmsTxt(items, site, { full = false } = {}) {
|
|
|
110
110
|
## The flow (for agents)
|
|
111
111
|
|
|
112
112
|
1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
|
|
113
|
-
ffmpeg, Chrome). The CLI is \`npx paper-motion\` (
|
|
113
|
+
ffmpeg, Chrome). The CLI is \`npx paper-motion\`; where these docs write \`pamo\`, type \`npx paper-motion\` (the npm package named \`pamo\` is unrelated). \`npx paper-motion --version\` prints the version. Every command answers \`--help\`. Agents load
|
|
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 in three steps (What it says: brief and inventory; Your video; Export). 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,29 @@ 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
|
|
155
|
+
(a chosen transition may be the join between two parts of a chain: \`"join": "wipe"\` in chain.json takes its id);
|
|
145
156
|
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
157
|
\`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
|
|
158
|
+
When the user chose a structure whose beats name blocks or shots (or several blocks in order), chain them instead:
|
|
159
|
+
\`npx paper-motion chain init <piece> --board <id> --option D\` installs each beat's block under
|
|
160
|
+
\`pieces/<piece>/<nn>-<id>/\` and writes \`chain.json\` (seconds per part; \`join\`: whip, cut, dissolve or dip);
|
|
161
|
+
write each part's \`CONFIG\` from its beat, then \`npx paper-motion chain <piece> --motion-blur 5\` renders every
|
|
162
|
+
part in the brand's look and joins them into one video. It warns when a part is cut before its land.
|
|
163
|
+
In the studio the stages are brief, inventory, video and final. When the user approves the inventory the studio
|
|
164
|
+
builds the video itself from what they kept, and then they edit it there, live (words, how each part arrives,
|
|
165
|
+
backgrounds, settings, lengths, transitions, order, adding and swapping parts, colours, type, pace); every edit
|
|
166
|
+
reaches you as a line. Review \`video\` to wait for their approval; they can render the final from the studio. \`chain --check\` warns when the total drifts from the brief's
|
|
167
|
+
length. Boards you share while a piece is open also appear in its studio (Look or Structure), so the user finds them
|
|
168
|
+
there; if your shell caps how long a command may run, share with \`--no-wait\` and then
|
|
169
|
+
\`npx paper-motion board --await <id> --wait 2\` until it prints the line (exit code 3 = not yet).
|
|
147
170
|
|
|
148
171
|
## Choosing well: the metadata
|
|
149
172
|
|
|
@@ -152,6 +175,16 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
152
175
|
- \`when\`: 2–3 "use it when…" phrases.
|
|
153
176
|
- \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
|
|
154
177
|
- Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
|
|
178
|
+
- Blocks and shots: \`needs\`, the content each one shows by kind (a \`figure\`, a \`change\`, a \`screen\`, a \`command\`, a
|
|
179
|
+
\`line\`…). Choose them by what the user has to show, not by mood. Gather \`pieces/<piece>/inventory.json\` with
|
|
180
|
+
\`npx paper-motion inventory <piece> --from <their site> --from README.md --from brand\` (it reads the page and
|
|
181
|
+
screenshots it, a markdown file, a folder, or the brand's Facts and assets, and writes proposals, each with its
|
|
182
|
+
source; \`npx paper-motion inventory kinds\` lists the kinds and their fields). Complete what a proposal still
|
|
183
|
+
needs (a command's real output), add what gathering cannot see, and hand it over:
|
|
184
|
+
\`npx paper-motion piece review <piece> inventory\`. The user keeps or removes each proposal in the studio; only
|
|
185
|
+
what they kept is used. Then let \`npx paper-motion plan <piece> --seconds <n>\` propose
|
|
186
|
+
the structures: it prints the structure board to share, and \`chain init <piece> --plan <letter>\` installs the
|
|
187
|
+
chosen one with the content already in every part's CONFIG.
|
|
155
188
|
- Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
|
|
156
189
|
**Every background is a color family**: a board with a \`look.palette\` shows each background recolored into that
|
|
157
190
|
palette (each of its colors keeps its tone and takes the nearest palette hue, so a dark one stays dark), and the
|
|
@@ -161,7 +194,8 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
161
194
|
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes, shots and blocks: \`duration\`, \`formats\` and \`beats\` (the structure as a
|
|
162
195
|
timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
|
|
163
196
|
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
|
|
197
|
+
format below), which you then build from scratch. Give each of its beats the \`id\` of the block or shot that shows
|
|
198
|
+
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
199
|
order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
|
|
166
200
|
reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
|
|
167
201
|
the reference for its block.
|
|
@@ -203,9 +237,9 @@ A recipe board may hold one option that is a structure of your own for this brie
|
|
|
203
237
|
|
|
204
238
|
\`\`\`json
|
|
205
239
|
{ "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" } ] } }
|
|
240
|
+
{ "name": "Hook", "from": 0, "to": 2, "what": "one line over the field", "id": "word-swap" },
|
|
241
|
+
{ "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product", "id": "browser-frame" },
|
|
242
|
+
{ "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action", "id": "svg-logo" } ] } }
|
|
209
243
|
\`\`\`
|
|
210
244
|
|
|
211
245
|
Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
|
|
@@ -261,7 +295,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
|
|
|
261
295
|
for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => typeof p !== 'string' || fs.existsSync(path.join(out, p)))); it.preview = Object.values(have).some((p) => typeof p === 'string') ? have : undefined; }
|
|
262
296
|
const index = {
|
|
263
297
|
name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
|
|
264
|
-
items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
|
|
298
|
+
items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, needs, repeat, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
|
|
265
299
|
};
|
|
266
300
|
fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
|
|
267
301
|
fs.mkdirSync(path.join(out, 'files'), { recursive: true });
|