paper-motion 0.1.0 → 0.2.1

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.
Files changed (75) hide show
  1. package/README.md +14 -0
  2. package/bin/pamo.mjs +134 -4
  3. package/examples/halo-explainer/audio/script.txt +8 -0
  4. package/examples/halo-explainer/audio/words.json +554 -0
  5. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  6. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  7. package/examples/halo-explainer/brand/motion.json +9 -0
  8. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  9. package/examples/halo-explainer/brand/sound.json +33 -0
  10. package/examples/halo-explainer/brand/tokens.json +97 -0
  11. package/examples/halo-explainer/brief.md +34 -0
  12. package/examples/halo-explainer/poster.jpg +0 -0
  13. package/examples/halo-explainer/scene.html +372 -0
  14. package/examples/orbit-keynote/audio/script.txt +7 -0
  15. package/examples/orbit-keynote/audio/words.json +494 -0
  16. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  17. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  18. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  19. package/examples/orbit-keynote/brand/motion.json +9 -0
  20. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  21. package/examples/orbit-keynote/brand/sound.json +45 -0
  22. package/examples/orbit-keynote/brand/tokens.json +106 -0
  23. package/examples/orbit-keynote/brief.md +25 -0
  24. package/examples/orbit-keynote/poster.jpg +0 -0
  25. package/examples/orbit-keynote/scene.html +323 -0
  26. package/lib/customize.mjs +162 -0
  27. package/lib/live-board.js +128 -0
  28. package/lib/registry.mjs +363 -0
  29. package/lib/render.mjs +3 -3
  30. package/lib/server.mjs +1 -0
  31. package/lib/sfx.mjs +5 -3
  32. package/package.json +1 -1
  33. package/runtime/core.js +269 -0
  34. package/runtime/index.js +3 -260
  35. package/runtime/kit.js +98 -0
  36. package/runtime/moves/bar-reveal.js +19 -0
  37. package/runtime/moves/blur-in.js +7 -0
  38. package/runtime/moves/cells.js +17 -0
  39. package/runtime/moves/crossfade.js +6 -0
  40. package/runtime/moves/cut.js +3 -0
  41. package/runtime/moves/dive-in.js +21 -0
  42. package/runtime/moves/draw-on.js +12 -0
  43. package/runtime/moves/drift.js +7 -0
  44. package/runtime/moves/flash.js +15 -0
  45. package/runtime/moves/flip-3d.js +9 -0
  46. package/runtime/moves/flip-in.js +12 -0
  47. package/runtime/moves/flip-lift.js +24 -0
  48. package/runtime/moves/glitch.js +16 -0
  49. package/runtime/moves/iris.js +12 -0
  50. package/runtime/moves/magic-move.js +49 -0
  51. package/runtime/moves/mask-reveal.js +10 -0
  52. package/runtime/moves/odometer.js +32 -0
  53. package/runtime/moves/orbit.js +8 -0
  54. package/runtime/moves/pop-in.js +12 -0
  55. package/runtime/moves/punch.js +9 -0
  56. package/runtime/moves/push.js +14 -0
  57. package/runtime/moves/rgb-split.js +7 -0
  58. package/runtime/moves/roll.js +20 -0
  59. package/runtime/moves/scan-reveal.js +10 -0
  60. package/runtime/moves/scramble.js +21 -0
  61. package/runtime/moves/shake.js +15 -0
  62. package/runtime/moves/slices.js +14 -0
  63. package/runtime/moves/snap-scroll.js +23 -0
  64. package/runtime/moves/squash-drop.js +21 -0
  65. package/runtime/moves/tile-swirl.js +29 -0
  66. package/runtime/moves/track-in.js +7 -0
  67. package/runtime/moves/type-on.js +41 -0
  68. package/runtime/moves/unfold.js +20 -0
  69. package/runtime/moves/whip-pan.js +21 -0
  70. package/runtime/moves/whip.js +14 -0
  71. package/runtime/moves/wipe.js +9 -0
  72. package/runtime/moves/word-rise.js +10 -0
  73. package/runtime/moves/zoom-through.js +13 -0
  74. package/runtime/moves.js +66 -146
  75. package/templates/move-demo/scene.html +4 -3
@@ -0,0 +1,363 @@
1
+ // The paper-motion registry: resources (moves, backgrounds, palettes, font pairs, recipes) that agents pick by
2
+ // name and install into a project as editable code or brand files. Source: registry/<type>/<id>/item.json in the
3
+ // repo. `build` publishes it for the site (registry.json, r/<id>.json, llms.txt); `add` installs from a local
4
+ // registry folder or from the site.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { apply as applyValues, parseSet } from './customize.mjs';
9
+
10
+ const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const REPO = path.resolve(ENGINE, '..', '..');
12
+ // until the paper-motion.app domain is connected, the live site is the vercel URL
13
+ export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
14
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
15
+ 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' };
16
+
17
+ /** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
18
+ export function resolveRegistry(given) {
19
+ const r = given ?? process.env.PAPER_MOTION_REGISTRY;
20
+ if (r) return /^https?:\/\//.test(r) ? { url: r.replace(/\/$/, '') } : { dir: path.resolve(r) };
21
+ const local = path.join(REPO, 'registry');
22
+ return fs.existsSync(path.join(local, 'schema.json')) ? { dir: local } : { url: SITE };
23
+ }
24
+
25
+ // ── reading and validating ──────────────────────────────────────
26
+
27
+ export function loadItems(dir) {
28
+ const out = [];
29
+ for (const type of fs.readdirSync(dir)) {
30
+ const td = path.join(dir, type);
31
+ if (!fs.statSync(td).isDirectory() || type.startsWith('_')) continue;
32
+ for (const id of fs.readdirSync(td)) {
33
+ const f = path.join(td, id, 'item.json');
34
+ if (fs.existsSync(f)) out.push({ ...JSON.parse(fs.readFileSync(f, 'utf8')), _dir: path.join(td, id) });
35
+ }
36
+ }
37
+ return out.sort((a, b) => TYPES.indexOf(a.type) - TYPES.indexOf(b.type) || a.id.localeCompare(b.id));
38
+ }
39
+
40
+ /** A small JSON Schema check (the subset registry/schema.json uses). Returns a list of problems. */
41
+ function check(value, schema, at = '') {
42
+ const errs = [];
43
+ const t = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
44
+ if (schema.type && schema.type !== t && !(schema.type === 'integer' && t === 'number' && Number.isInteger(value))) return [`${at || '/'}: expected ${schema.type}, got ${t}`];
45
+ if (schema.type === 'integer') { if (schema.minimum !== undefined && value < schema.minimum) errs.push(`${at}: below ${schema.minimum}`); if (schema.maximum !== undefined && value > schema.maximum) errs.push(`${at}: above ${schema.maximum}`); }
46
+ if (schema.enum && !schema.enum.includes(value)) errs.push(`${at}: "${value}" is not one of ${schema.enum.join(', ')}`);
47
+ if (t === 'string') {
48
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) errs.push(`${at}: "${value}" does not match ${schema.pattern}`);
49
+ if (schema.maxLength && value.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength} characters`);
50
+ }
51
+ if (t === 'array') {
52
+ if (schema.minItems && value.length < schema.minItems) errs.push(`${at}: needs at least ${schema.minItems} items`);
53
+ if (schema.items) value.forEach((v, i) => errs.push(...check(v, schema.items, `${at}/${i}`)));
54
+ }
55
+ if (t === 'object') {
56
+ for (const k of schema.required ?? []) if (!(k in value)) errs.push(`${at}/${k}: required`);
57
+ for (const [k, v] of Object.entries(value)) {
58
+ if (schema.properties?.[k]) errs.push(...check(v, schema.properties[k], `${at}/${k}`));
59
+ else if (schema.additionalProperties === false && !k.startsWith('_')) errs.push(`${at}/${k}: not allowed`);
60
+ }
61
+ }
62
+ return errs;
63
+ }
64
+
65
+ export function validate(dir) {
66
+ const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'));
67
+ const items = loadItems(dir), ids = new Set(), problems = [];
68
+ for (const it of items) {
69
+ const where = path.relative(REPO, it._dir);
70
+ for (const e of check(it, schema)) problems.push(`${where}: ${e}`);
71
+ if (ids.has(it.id)) problems.push(`${where}: duplicate id "${it.id}"`);
72
+ ids.add(it.id);
73
+ if (path.basename(it._dir) !== it.id) problems.push(`${where}: folder name must equal id "${it.id}"`);
74
+ if (path.basename(path.dirname(it._dir)) !== it.type) problems.push(`${where}: lives under ${path.basename(path.dirname(it._dir))}/ but type is "${it.type}"`);
75
+ for (const f of it.files ?? []) if (!fs.existsSync(path.join(REPO, f.from))) problems.push(`${where}: missing file ${f.from}`);
76
+ }
77
+ for (const it of items) for (const r of [...(it.requires?.items ?? []), ...(it.related ?? [])]) if (!ids.has(r)) problems.push(`${it.id}: refers to unknown resource "${r}"`);
78
+ return { items, problems };
79
+ }
80
+
81
+ // ── publishing ──────────────────────────────────────────────────
82
+
83
+ /** Imports inside the repo point at the runtime; in a user's project they point at the package. */
84
+ export const rewriteImports = (code) => code.replace(/(['"])\.\.\/(index|core)\.js\1/g, "'paper-motion'").replace(/(['"])\.\.\/kit\.js\1/g, "'paper-motion/kit'");
85
+
86
+ const BINARY = /\.(png|jpe?g|webp|gif|avif|woff2?|ttf|otf|mp4|webm)$/i;
87
+
88
+ /**
89
+ * The full resource as served at r/<id>.json: the item plus its files, ready to write. Text files carry their content;
90
+ * binary files (images, fonts) carry a URL under <site>/files/ instead.
91
+ */
92
+ export function resolveItem(it, site = SITE) {
93
+ const { _dir, ...item } = it;
94
+ return { ...item, files: (it.files ?? []).map((f) => (BINARY.test(f.from)
95
+ ? { path: f.to, url: `${site}/files/${path.basename(f.from)}`, from: f.from }
96
+ : { path: f.to, content: rewriteImports(fs.readFileSync(path.join(REPO, f.from), 'utf8')) })) };
97
+ }
98
+
99
+ const summaryLine = (it, site) => `- [${it.title}](${site}/r/${it.id}.json): ${it.summary}${it.tags?.length ? ` _(${it.tags.filter((t) => t !== it.type).join(', ')})_` : ''}`;
100
+
101
+ export function llmsTxt(items, site, { full = false } = {}) {
102
+ const head = `# paper-motion
103
+
104
+ > Motion resources for AI agents: component transitions, transitions, text effects, camera moves, backgrounds
105
+ > (Paper shaders), palettes, font pairs and video recipes, each with a name. You pick them for the user's brand,
106
+ > propose options as a **board** link they open in a browser, install what they choose as code they own, and render
107
+ > the video with the paper-motion engine. Nothing here renders on a server: every preview plays in the user's browser.
108
+
109
+ ## The flow (for agents)
110
+
111
+ 1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
112
+ ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`.
113
+ 2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
114
+ line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
115
+ "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
116
+ Mirror it back before proposing anything.
117
+ 3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
118
+ this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
119
+ (format below). The user opens it, sees the options live in their look, may tune one, and pastes back one line:
120
+ \`paper-motion: B · mesh-sage --set speed=0.8 (bg-1)\`, or \`paper-motion: none of bg-1, calmer ones\`. The reply
121
+ carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what they saw.
122
+ 4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
123
+ \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
124
+ fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
125
+ whose prompt carries the whole \`pamo add\` command. \`add\` never overwrites a value already in the project: when
126
+ the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
127
+ \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
128
+ from \`tokens.json\` \`brand\`: write those from the interview before rendering.
129
+ 5. **Make the video.** Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
130
+ \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
131
+
132
+ ## Choosing well: the metadata
133
+
134
+ ${site}/registry.json is the index; every item carries, beside id, type, title, summary, tags and the names of its
135
+ \`params\` (ranges and defaults are in ${site}/r/<id>.json, fetch it before writing a \`--set\`):
136
+ - \`when\`: 2–3 "use it when…" phrases.
137
+ - \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
138
+ - Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
139
+ - Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
140
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
141
+
142
+ \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
143
+ sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
144
+ ground, figure and accent so resources with white type are not punished); \`pamo search --type component --mood calm
145
+ --energy "<=3" --role swap\`.
146
+
147
+ Worked example, four backgrounds for a dark cobalt brand: filter \`type=background\`, \`mode=dark\`; rank by nearest
148
+ \`colors\` to the brand's ground, figure and accent; spread the \`motion\` as far as the catalog allows (a quiet one,
149
+ a lively one); prefer
150
+ \`mood\` overlapping the brand's words; tune one toward the accent (\`dither-slate:colorFront=#3d6bff\`); write a
151
+ one-line \`--note\` for each; share the link.
152
+
153
+ ## The board link format
154
+
155
+ \`${site}/b#<payload>\` where payload = base64url(deflate-raw(JSON)). The JSON:
156
+
157
+ \`\`\`json
158
+ { "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
159
+ "reply": "bg-1",
160
+ "look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
161
+ "options": [
162
+ { "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
163
+ { "label": "B", "id": "neuro-slate", "values": {}, "note": "a technical web" },
164
+ { "label": "C", "id": "dither-slate", "values": { "colorFront": "#3d6bff" }, "note": "pixels in your accent" },
165
+ { "label": "D", "id": "grain-slate", "values": {} } ] }
166
+ \`\`\`
167
+
168
+ Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
169
+ \`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
170
+ defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
171
+
172
+ Resources by type (${items.length} in all):
173
+ `;
174
+ const sections = TYPES.filter((t) => items.some((i) => i.type === t)).map((t) => {
175
+ const list = items.filter((i) => i.type === t);
176
+ if (!full) return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => summaryLine(i, site)).join('\n')}\n`;
177
+ return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => [
178
+ `### ${i.title} · \`${i.id}\``, '', i.summary, '',
179
+ i.signature ? `Signature: \`${i.signature}\`` : '',
180
+ i.params?.length ? `Params: ${i.params.map((p) => `\`${p.name}\`${p.default !== undefined ? ` = ${JSON.stringify(p.default)}` : ''}${p.description ? ` (${p.description})` : ''}`).join('; ')}` : '',
181
+ i.notes?.length ? i.notes.map((n) => `- ${n}`).join('\n') : '',
182
+ `Install: \`npx paper-motion add ${i.id}\` · ${site}/r/${i.id}.json`, '',
183
+ '```js', i.usage, '```', '',
184
+ ].filter((x) => x !== '').join('\n')).join('\n\n')}\n`;
185
+ });
186
+ return head + sections.join('');
187
+ }
188
+
189
+ /**
190
+ * The site's live player: the moves, kit and core (browser-safe), the demo harness with its imports pointed at them,
191
+ * its stylesheet scoped to `.pm-play`, and its fonts. The site imports them at runtime from /play/.
192
+ */
193
+ function publishPlayer(dir, out) {
194
+ const RT = path.join(ENGINE, 'runtime'), demo = path.join(dir, '_demo');
195
+ fs.rmSync(out, { recursive: true, force: true });
196
+ fs.mkdirSync(path.join(out, 'engine', 'moves'), { recursive: true });
197
+ for (const f of ['core.js', 'kit.js', 'moves.js']) fs.copyFileSync(path.join(RT, f), path.join(out, 'engine', f));
198
+ for (const f of fs.readdirSync(path.join(RT, 'moves'))) fs.copyFileSync(path.join(RT, 'moves', f), path.join(out, 'engine', 'moves', f));
199
+ const js = fs.readFileSync(path.join(demo, 'demos.js'), 'utf8')
200
+ .replace(/import \{ scene, ([^}]*) \} from 'paper-motion';/, "import { $1 } from './engine/core.js';\nconst scene = () => { throw new Error('the site mounts demos with mount()'); };")
201
+ .replace(/from 'paper-motion\/moves'/g, "from './engine/moves.js'");
202
+ fs.writeFileSync(path.join(out, 'demos.js'), js);
203
+ const css = fs.readFileSync(path.join(demo, 'demo.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^([^@\s{][^{]*)\{/gm, (m, sel) => `${sel.split(',').map((x) => (x.trim().startsWith('#stage') ? x.replace('#stage', '.pm-play') : `.pm-play ${x.trim()}`)).join(', ')} {`);
204
+ fs.writeFileSync(path.join(out, 'demo.css'), css);
205
+ fs.mkdirSync(path.join(out, 'fonts'), { recursive: true });
206
+ for (const f of fs.readdirSync(path.join(demo, 'fonts'))) fs.copyFileSync(path.join(demo, 'fonts', f), path.join(out, 'fonts', f));
207
+ }
208
+
209
+ /** Writes registry.json, r/<id>.json, schema.json, llms.txt and llms-full.txt into `out`. */
210
+ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
211
+ const { items, problems } = validate(dir);
212
+ if (problems.length) { const e = new Error(`registry: ${problems.length} problem(s)\n ${problems.join('\n ')}`); e.problems = problems; throw e; }
213
+ fs.mkdirSync(path.join(out, 'r'), { recursive: true });
214
+ // publish only the preview files that were rendered: a background plays live and may have none
215
+ 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; }
216
+ const index = {
217
+ name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
218
+ items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, 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, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
219
+ };
220
+ fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
221
+ fs.mkdirSync(path.join(out, 'files'), { recursive: true });
222
+ for (const it of items) {
223
+ fs.writeFileSync(path.join(out, 'r', `${it.id}.json`), JSON.stringify(resolveItem(it, site), null, 2));
224
+ for (const f of it.files ?? []) if (BINARY.test(f.from)) fs.copyFileSync(path.join(REPO, f.from), path.join(out, 'files', path.basename(f.from)));
225
+ }
226
+ fs.copyFileSync(path.join(dir, 'schema.json'), path.join(out, 'schema.json'));
227
+ publishPlayer(dir, path.join(out, 'play'));
228
+ fs.writeFileSync(path.join(out, 'llms.txt'), llmsTxt(items, site));
229
+ fs.writeFileSync(path.join(out, 'llms-full.txt'), llmsTxt(items, site, { full: true }));
230
+ return { count: items.length, out };
231
+ }
232
+
233
+ // ── installing ──────────────────────────────────────────────────
234
+
235
+ async function fetchJson(url) {
236
+ const r = await fetch(url);
237
+ if (!r.ok) throw new Error(`${url}: ${r.status} ${r.statusText}`);
238
+ return r.json();
239
+ }
240
+
241
+ /** One resolved resource (with file contents) from a local folder or the site. */
242
+ export async function getItem(id, reg = resolveRegistry()) {
243
+ if (reg.dir) {
244
+ const it = loadItems(reg.dir).find((i) => i.id === id);
245
+ if (!it) throw new Error(`no resource "${id}" in ${reg.dir} (try \`pamo search\`)`);
246
+ return resolveItem(it);
247
+ }
248
+ return fetchJson(`${reg.url}/r/${id}.json`);
249
+ }
250
+
251
+ export async function getIndex(reg = resolveRegistry()) {
252
+ if (reg.dir) return { items: loadItems(reg.dir).map(({ _dir, ...i }) => i) };
253
+ return fetchJson(`${reg.url}/registry.json`);
254
+ }
255
+
256
+ const readJson = (f, fallback) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : fallback);
257
+ const writeJson = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n'); };
258
+
259
+ /** Deep-merges `frag` into `base`; keys that already hold a different value are reported, and replaced only with force. */
260
+ function mergeInto(base, frag, force, at = '', conflicts = []) {
261
+ for (const [k, v] of Object.entries(frag)) {
262
+ const key = at ? `${at}.${k}` : k;
263
+ if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) mergeInto(base[k], v, force, key, conflicts);
264
+ else if (base[k] !== undefined && JSON.stringify(base[k]) !== JSON.stringify(v)) { conflicts.push(key); if (force) base[k] = v; }
265
+ else base[k] = v;
266
+ }
267
+ return conflicts;
268
+ }
269
+
270
+ /**
271
+ * Installs resources into the project at `cwd`, resolving `requires.items` first. Returns a report per resource.
272
+ * Never overwrites a changed file or a different brand value unless `force`.
273
+ */
274
+ // what a recipe reads beside the tokens and the shader set; written once, when the project has none
275
+ const MOTION_DEFAULT = { pace: 'medium', camera: 'still', text: 'rise', color: 'constant', ending: 'wordmark', timing: null, moves: [] };
276
+ const SOUND_DEFAULT = { palette: 'soft', lowpass: 2400, cues: { transition: { type: 'sweep', dur: 0.7, gain: 0.07 }, land: { type: 'thud', gain: 0.09 }, text: { type: 'tick', freq: 900, gain: 0.04 }, bed: null }, keys: { bank: null, style: 'creamy', gain: 0.12 }, music: null, loudness: -14 };
277
+
278
+ export async function add(ids, { cwd = process.cwd(), registry, force = false, name, brand, set = [] } = {}) {
279
+ const reg = resolveRegistry(registry), done = new Map(), report = [];
280
+ const scaffold = (r) => {
281
+ // a brand folder a recipe can run on: its name, a pace and sound cues (silent pieces are the usual surprise)
282
+ const tokensFile = path.join(cwd, 'brand/tokens.json'), tokens = readJson(tokensFile, {});
283
+ if (brand && (force || !tokens.brand)) { tokens.brand = brand; writeJson(tokensFile, tokens); r.wrote.push('brand/tokens.json brand'); }
284
+ for (const [file, def] of [['brand/motion.json', MOTION_DEFAULT], ['brand/sound.json', SOUND_DEFAULT]]) {
285
+ const f = path.join(cwd, file);
286
+ if (!fs.existsSync(f)) { writeJson(f, def); r.wrote.push(`${file} (defaults)`); }
287
+ }
288
+ if (!tokens.brand && !brand) r.notes.push('the brand has no name yet: pamo add --brand "Name" (recipes show it as the wordmark)');
289
+ };
290
+ const lockFile = path.join(cwd, 'paper-motion.json'), lock = readJson(lockFile, { registry: reg.url ?? 'local', items: {} });
291
+ async function one(id) {
292
+ if (done.has(id)) return;
293
+ done.set(id, true);
294
+ let it = await getItem(id, reg);
295
+ for (const dep of it.requires?.items ?? []) await one(dep);
296
+ // --set key=value (or --set id:key=value with several ids): the same output the site's code tabs show
297
+ const pairs = set.filter((x) => (x.includes(':') && x.indexOf(':') < x.indexOf('=') ? x.startsWith(`${id}:`) : ids.length === 1 && ids[0] === id)).map((x) => (x.startsWith(`${id}:`) ? x.slice(id.length + 1) : x));
298
+ if (pairs.length) it = { ...it, ...applyValues(it, parseSet(it, pairs)) };
299
+ const r = { id, type: it.type, wrote: [], skipped: [], conflicts: [], usage: it.usage, notes: it.notes ?? [] };
300
+ const kind = it.install?.kind ?? 'code';
301
+ const base = kind === 'scene' ? path.join('pieces', name ?? it.id) : '';
302
+ for (const f of it.files ?? []) {
303
+ const rel = kind === 'scene' ? path.join(base, path.basename(f.path)) : f.path, dest = path.join(cwd, rel);
304
+ if (f.content === undefined) {
305
+ // a binary asset: kept if present, else copied from the local registry or downloaded from the site
306
+ if (fs.existsSync(dest) && !force) continue;
307
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
308
+ if (reg.dir && f.from && fs.existsSync(path.join(REPO, f.from))) fs.copyFileSync(path.join(REPO, f.from), dest);
309
+ else { const res = await fetch(f.url); if (!res.ok) throw new Error(`${f.url}: ${res.status}`); fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); }
310
+ r.wrote.push(rel); continue;
311
+ }
312
+ if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') !== f.content && !force) { r.skipped.push(rel); continue; }
313
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
314
+ fs.writeFileSync(dest, f.content);
315
+ r.wrote.push(rel);
316
+ }
317
+ if (kind === 'shader-set') {
318
+ const f = path.join(cwd, 'brand/shader-set/set.json'), set = readJson(f, { format: { width: 1920, height: 1080, duration: 6, fps: 30 }, entries: [] });
319
+ set.entries ??= [];
320
+ const entry = { id: it.id, source: 'paper-motion.app', ...it.install.entry }, at = set.entries.findIndex((e) => e.id === it.id);
321
+ if (at < 0) set.entries.push(entry);
322
+ else if (JSON.stringify(set.entries[at]) !== JSON.stringify(entry)) { if (force) set.entries[at] = entry; else r.conflicts.push(`shader-set entry "${it.id}" already exists`); }
323
+ writeJson(f, set); r.wrote.push('brand/shader-set/set.json');
324
+ }
325
+ if (kind === 'tokens') {
326
+ const f = path.join(cwd, 'brand/tokens.json'), tokens = readJson(f, {});
327
+ r.conflicts.push(...mergeInto(tokens, it.install.entry ?? {}, force).map((k) => `brand/tokens.json ${k}`));
328
+ writeJson(f, tokens); r.wrote.push('brand/tokens.json');
329
+ }
330
+ if (kind === 'tokens' || kind === 'scene') scaffold(r);
331
+ lock.items[it.id] = it.version;
332
+ report.push(r);
333
+ }
334
+ for (const id of ids) await one(id);
335
+ writeJson(lockFile, lock);
336
+ return report;
337
+ }
338
+
339
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
340
+ /** Distance between two hex colors (0 = same, ~441 = black to white). */
341
+ export const colorDistance = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
342
+ /** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
343
+ export const colorFit = (item, brand) => {
344
+ if (!item.colors?.length || !brand.length) return null;
345
+ const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
346
+ // a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
347
+ return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
348
+ };
349
+
350
+ /**
351
+ * Finds resources: free words over id/title/summary/tags/when, plus filters: type, mode (light|dark), mood,
352
+ * motion (still|gentle|lively), role, energy ("3", "2-4", "<=3"), near (hex colors: sorts by closeness).
353
+ */
354
+ export async function search(query, opts = {}) {
355
+ const { items } = await getIndex(resolveRegistry(opts.registry));
356
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
357
+ const hit = (i) => { const hay = [i.id, i.type, i.title, i.summary, ...(i.tags ?? []), ...(i.when ?? []), ...(i.mood ?? [])].join(' ').toLowerCase(); return words.every((w) => hay.includes(w)); };
358
+ const en = (v) => { if (!opts.energy) return true; const e = String(opts.energy), m = e.match(/^(\d)-(\d)$/); if (m) return v >= +m[1] && v <= +m[2]; if (e.startsWith('<=')) return v <= +e.slice(2); if (e.startsWith('>=')) return v >= +e.slice(2); return v === +e; };
359
+ const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
360
+ let out = items.filter((i) => (!opts.type || i.type === opts.type) && (!opts.mode || i.mode === opts.mode) && (!opts.mood || (i.mood ?? []).includes(opts.mood)) && (!opts.motion || i.motion === opts.motion) && (!opts.role || i.role === opts.role) && (i.energy === undefined || en(i.energy)) && hit(i));
361
+ if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
362
+ return out;
363
+ }
package/lib/render.mjs CHANGED
@@ -94,12 +94,12 @@ export async function renderSfx({ url, out, from = 0, to }) {
94
94
  } finally { await browser.close(); }
95
95
  }
96
96
 
97
- async function sfxFromPage(s, { from = 0, to, out }) {
97
+ async function sfxFromPage(s, { from = 0, to, out, normalize = true }) {
98
98
  const { cues, audio } = await s.page.evaluate(() => ({ cues: window.__pm.cues ?? [], audio: window.__pm.audio ?? {} }));
99
99
  if (!cues.length) return null;
100
100
  const end = to ?? s.config.duration;
101
101
  const shifted = cues.filter((c) => c.t < end).map((c) => ({ ...c, t: c.t - from }));
102
- writeWav(out, synthesize(shifted, { duration: end - from, ...audio }));
102
+ writeWav(out, synthesize(shifted, { duration: end - from, ...audio, normalize }));
103
103
  return { out, cues: cues.length };
104
104
  }
105
105
 
@@ -131,7 +131,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
131
131
 
132
132
  let sfxWav = null;
133
133
  if (sfx && !fmt.gif && !fmt.seq) {
134
- const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, out: path.join(tmp, 'sfx.wav') });
134
+ const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, out: path.join(tmp, 'sfx.wav'), normalize: !audio }); // with an external track, effects keep their own gains
135
135
  sfxWav = r?.out ?? null;
136
136
  }
137
137
 
package/lib/server.mjs CHANGED
@@ -21,6 +21,7 @@ const IMPORT_MAP = {
21
21
  imports: {
22
22
  'paper-motion': '/__pm/runtime/index.js',
23
23
  'paper-motion/moves': '/__pm/runtime/moves.js',
24
+ 'paper-motion/kit': '/__pm/runtime/kit.js',
24
25
  'paper-motion/brand': '/__pm/runtime/brand.js',
25
26
  '@paper-design/shaders': '/__pm/node_modules/@paper-design/shaders/dist/index.js',
26
27
  '@paper-design/shaders-react': '/__pm/node_modules/@paper-design/shaders-react/dist/index.js',
package/lib/sfx.mjs CHANGED
@@ -226,10 +226,12 @@ export const SFX_TYPES = Object.keys(VOICES);
226
226
 
227
227
  /**
228
228
  * @param cues [{ t, type, pan?, seed?, ...voiceOpts }]
229
- * @param options { duration, lowpass=2000, highpass=30, gain=1, peak=0.8 }
229
+ * @param options { duration, lowpass=2000, highpass=30, gain=1, peak=0.8, normalize=true }
230
+ * normalize: true scales the mix up or down to `peak`; false keeps the cues' own gains and only
231
+ * limits overs (used when the scene brings its own audio, so effects sit where their gains put them)
230
232
  * @returns { left: Float32Array, right: Float32Array, sampleRate }
231
233
  */
232
- export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain = 1, peak = 0.8 } = {}) {
234
+ export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain = 1, peak = 0.8, normalize = true } = {}) {
233
235
  const n = Math.ceil(duration * SR);
234
236
  const L = new Float32Array(n), R = new Float32Array(n);
235
237
  // Voices marked `bright` (keys) go through their own, gentler low-pass: a keystroke's click lives at 1–4 kHz.
@@ -260,7 +262,7 @@ export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain
260
262
  });
261
263
  let max = 1e-9;
262
264
  for (let i = 0; i < n; i++) max = Math.max(max, Math.abs(L[i]), Math.abs(R[i]));
263
- const k = (peak / max) * gain;
265
+ const k = (normalize ? peak / max : Math.min(1, peak / max)) * gain;
264
266
  const fadeOut = Math.min(n, Math.round(0.05 * SR));
265
267
  for (let i = 0; i < n; i++) {
266
268
  const f = i > n - fadeOut ? (n - i) / fadeOut : 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",