paper-motion 0.0.0-stage → 0.2.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.
Files changed (113) hide show
  1. package/README.md +59 -2
  2. package/bin/pamo.mjs +664 -0
  3. package/examples/breath/brief.md +11 -0
  4. package/examples/breath/poster.jpg +0 -0
  5. package/examples/breath/scene.html +59 -0
  6. package/examples/chrome-endcard/brief.md +7 -0
  7. package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
  8. package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
  9. package/examples/chrome-endcard/poster.jpg +0 -0
  10. package/examples/chrome-endcard/scene.html +68 -0
  11. package/examples/chrome-endcard/wordmark.js +16 -0
  12. package/examples/halo-explainer/audio/script.txt +8 -0
  13. package/examples/halo-explainer/audio/words.json +554 -0
  14. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  15. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  16. package/examples/halo-explainer/brand/motion.json +9 -0
  17. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  18. package/examples/halo-explainer/brand/sound.json +33 -0
  19. package/examples/halo-explainer/brand/tokens.json +97 -0
  20. package/examples/halo-explainer/brief.md +34 -0
  21. package/examples/halo-explainer/poster.jpg +0 -0
  22. package/examples/halo-explainer/scene.html +372 -0
  23. package/examples/install-loop/brief.md +7 -0
  24. package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
  25. package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
  26. package/examples/install-loop/poster.jpg +0 -0
  27. package/examples/install-loop/scene.html +46 -0
  28. package/examples/orbit-keynote/audio/script.txt +7 -0
  29. package/examples/orbit-keynote/audio/words.json +494 -0
  30. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  31. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  32. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  33. package/examples/orbit-keynote/brand/motion.json +9 -0
  34. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  35. package/examples/orbit-keynote/brand/sound.json +45 -0
  36. package/examples/orbit-keynote/brand/tokens.json +106 -0
  37. package/examples/orbit-keynote/brief.md +25 -0
  38. package/examples/orbit-keynote/poster.jpg +0 -0
  39. package/examples/orbit-keynote/scene.html +323 -0
  40. package/examples/signal/brief.md +10 -0
  41. package/examples/signal/poster.jpg +0 -0
  42. package/examples/signal/scene.html +50 -0
  43. package/lib/board.mjs +95 -0
  44. package/lib/browser.mjs +88 -0
  45. package/lib/catalog.mjs +77 -0
  46. package/lib/customize.mjs +162 -0
  47. package/lib/keys.mjs +93 -0
  48. package/lib/live-board.js +128 -0
  49. package/lib/modules.mjs +23 -0
  50. package/lib/react-hooks.mjs +9 -0
  51. package/lib/registry.mjs +344 -0
  52. package/lib/render.mjs +193 -0
  53. package/lib/server.mjs +89 -0
  54. package/lib/sfx.mjs +287 -0
  55. package/lib/shaderset.mjs +65 -0
  56. package/lib/wordmark.mjs +64 -0
  57. package/package.json +46 -4
  58. package/runtime/brand.js +71 -0
  59. package/runtime/core.js +269 -0
  60. package/runtime/index.js +349 -0
  61. package/runtime/jsx-shim.js +3 -0
  62. package/runtime/kit.js +98 -0
  63. package/runtime/moves/bar-reveal.js +19 -0
  64. package/runtime/moves/blur-in.js +7 -0
  65. package/runtime/moves/cells.js +17 -0
  66. package/runtime/moves/crossfade.js +6 -0
  67. package/runtime/moves/cut.js +3 -0
  68. package/runtime/moves/dive-in.js +21 -0
  69. package/runtime/moves/draw-on.js +12 -0
  70. package/runtime/moves/drift.js +7 -0
  71. package/runtime/moves/flash.js +15 -0
  72. package/runtime/moves/flip-3d.js +9 -0
  73. package/runtime/moves/flip-in.js +12 -0
  74. package/runtime/moves/flip-lift.js +24 -0
  75. package/runtime/moves/glitch.js +16 -0
  76. package/runtime/moves/iris.js +12 -0
  77. package/runtime/moves/magic-move.js +49 -0
  78. package/runtime/moves/mask-reveal.js +10 -0
  79. package/runtime/moves/odometer.js +32 -0
  80. package/runtime/moves/orbit.js +8 -0
  81. package/runtime/moves/pop-in.js +12 -0
  82. package/runtime/moves/punch.js +9 -0
  83. package/runtime/moves/push.js +14 -0
  84. package/runtime/moves/rgb-split.js +7 -0
  85. package/runtime/moves/roll.js +20 -0
  86. package/runtime/moves/scan-reveal.js +10 -0
  87. package/runtime/moves/scramble.js +21 -0
  88. package/runtime/moves/shake.js +15 -0
  89. package/runtime/moves/slices.js +14 -0
  90. package/runtime/moves/snap-scroll.js +23 -0
  91. package/runtime/moves/squash-drop.js +21 -0
  92. package/runtime/moves/tile-swirl.js +29 -0
  93. package/runtime/moves/track-in.js +7 -0
  94. package/runtime/moves/type-on.js +41 -0
  95. package/runtime/moves/unfold.js +20 -0
  96. package/runtime/moves/whip-pan.js +21 -0
  97. package/runtime/moves/whip.js +14 -0
  98. package/runtime/moves/wipe.js +9 -0
  99. package/runtime/moves/word-rise.js +10 -0
  100. package/runtime/moves/zoom-through.js +13 -0
  101. package/runtime/moves.js +94 -0
  102. package/runtime/player.js +69 -0
  103. package/runtime/react-shim.js +15 -0
  104. package/templates/blank/brief.md +39 -0
  105. package/templates/blank/scene.html +44 -0
  106. package/templates/brand-sheet/index.html +241 -0
  107. package/templates/move-demo/brief.md +4 -0
  108. package/templates/move-demo/scene.html +158 -0
  109. package/templates/piece/brief.md +39 -0
  110. package/templates/piece/scene.html +62 -0
  111. package/templates/shader-set/shot.js +47 -0
  112. package/templates/still/brief.md +3 -0
  113. package/templates/still/scene.html +56 -0
@@ -0,0 +1,23 @@
1
+ // Where an engine dependency lives on disk. Inside the repo it is packages/engine/node_modules; installed from npm,
2
+ // npm hoists it next to the engine (…/node_modules/<dep>, or npx's cache). Walk up like Node's resolver does.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+
9
+ const found = new Map();
10
+ /** Absolute folder of an installed package (e.g. '@paper-design/shaders'), or null. */
11
+ export function moduleDir(name) {
12
+ if (found.has(name)) return found.get(name);
13
+ let dir = ENGINE, hit = null;
14
+ for (;;) {
15
+ const d = path.join(dir, 'node_modules', name);
16
+ if (fs.existsSync(path.join(d, 'package.json'))) { hit = d; break; }
17
+ const up = path.dirname(dir);
18
+ if (up === dir) break;
19
+ dir = up;
20
+ }
21
+ found.set(name, hit);
22
+ return hit;
23
+ }
@@ -0,0 +1,9 @@
1
+ // Node module hook: resolve `react` to the paper-motion shim so the official
2
+ // shader components can be inspected in Node (used by `paper-motion shaders`).
3
+ const shim = new URL('../runtime/react-shim.js', import.meta.url).href;
4
+ const jsx = new URL('../runtime/jsx-shim.js', import.meta.url).href;
5
+ export async function resolve(specifier, context, next) {
6
+ if (specifier === 'react') return { url: shim, shortCircuit: true, format: 'module' };
7
+ if (specifier === 'react/jsx-runtime') return { url: jsx, shortCircuit: true, format: 'module' };
8
+ return next(specifier, context);
9
+ }
@@ -0,0 +1,344 @@
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
+ export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.app').replace(/\/$/, '');
13
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
14
+ 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' };
15
+
16
+ /** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
17
+ export function resolveRegistry(given) {
18
+ const r = given ?? process.env.PAPER_MOTION_REGISTRY;
19
+ if (r) return /^https?:\/\//.test(r) ? { url: r.replace(/\/$/, '') } : { dir: path.resolve(r) };
20
+ const local = path.join(REPO, 'registry');
21
+ return fs.existsSync(path.join(local, 'schema.json')) ? { dir: local } : { url: SITE };
22
+ }
23
+
24
+ // ── reading and validating ──────────────────────────────────────
25
+
26
+ export function loadItems(dir) {
27
+ const out = [];
28
+ for (const type of fs.readdirSync(dir)) {
29
+ const td = path.join(dir, type);
30
+ if (!fs.statSync(td).isDirectory() || type.startsWith('_')) continue;
31
+ for (const id of fs.readdirSync(td)) {
32
+ const f = path.join(td, id, 'item.json');
33
+ if (fs.existsSync(f)) out.push({ ...JSON.parse(fs.readFileSync(f, 'utf8')), _dir: path.join(td, id) });
34
+ }
35
+ }
36
+ return out.sort((a, b) => TYPES.indexOf(a.type) - TYPES.indexOf(b.type) || a.id.localeCompare(b.id));
37
+ }
38
+
39
+ /** A small JSON Schema check (the subset registry/schema.json uses). Returns a list of problems. */
40
+ function check(value, schema, at = '') {
41
+ const errs = [];
42
+ const t = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
43
+ if (schema.type && schema.type !== t && !(schema.type === 'integer' && t === 'number' && Number.isInteger(value))) return [`${at || '/'}: expected ${schema.type}, got ${t}`];
44
+ 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}`); }
45
+ if (schema.enum && !schema.enum.includes(value)) errs.push(`${at}: "${value}" is not one of ${schema.enum.join(', ')}`);
46
+ if (t === 'string') {
47
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) errs.push(`${at}: "${value}" does not match ${schema.pattern}`);
48
+ if (schema.maxLength && value.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength} characters`);
49
+ }
50
+ if (t === 'array') {
51
+ if (schema.minItems && value.length < schema.minItems) errs.push(`${at}: needs at least ${schema.minItems} items`);
52
+ if (schema.items) value.forEach((v, i) => errs.push(...check(v, schema.items, `${at}/${i}`)));
53
+ }
54
+ if (t === 'object') {
55
+ for (const k of schema.required ?? []) if (!(k in value)) errs.push(`${at}/${k}: required`);
56
+ for (const [k, v] of Object.entries(value)) {
57
+ if (schema.properties?.[k]) errs.push(...check(v, schema.properties[k], `${at}/${k}`));
58
+ else if (schema.additionalProperties === false && !k.startsWith('_')) errs.push(`${at}/${k}: not allowed`);
59
+ }
60
+ }
61
+ return errs;
62
+ }
63
+
64
+ export function validate(dir) {
65
+ const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'));
66
+ const items = loadItems(dir), ids = new Set(), problems = [];
67
+ for (const it of items) {
68
+ const where = path.relative(REPO, it._dir);
69
+ for (const e of check(it, schema)) problems.push(`${where}: ${e}`);
70
+ if (ids.has(it.id)) problems.push(`${where}: duplicate id "${it.id}"`);
71
+ ids.add(it.id);
72
+ if (path.basename(it._dir) !== it.id) problems.push(`${where}: folder name must equal id "${it.id}"`);
73
+ 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}"`);
74
+ for (const f of it.files ?? []) if (!fs.existsSync(path.join(REPO, f.from))) problems.push(`${where}: missing file ${f.from}`);
75
+ }
76
+ 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}"`);
77
+ return { items, problems };
78
+ }
79
+
80
+ // ── publishing ──────────────────────────────────────────────────
81
+
82
+ /** Imports inside the repo point at the runtime; in a user's project they point at the package. */
83
+ export const rewriteImports = (code) => code.replace(/(['"])\.\.\/(index|core)\.js\1/g, "'paper-motion'").replace(/(['"])\.\.\/kit\.js\1/g, "'paper-motion/kit'");
84
+
85
+ const BINARY = /\.(png|jpe?g|webp|gif|avif|woff2?|ttf|otf|mp4|webm)$/i;
86
+
87
+ /**
88
+ * The full resource as served at r/<id>.json: the item plus its files, ready to write. Text files carry their content;
89
+ * binary files (images, fonts) carry a URL under <site>/files/ instead.
90
+ */
91
+ export function resolveItem(it, site = SITE) {
92
+ const { _dir, ...item } = it;
93
+ return { ...item, files: (it.files ?? []).map((f) => (BINARY.test(f.from)
94
+ ? { path: f.to, url: `${site}/files/${path.basename(f.from)}`, from: f.from }
95
+ : { path: f.to, content: rewriteImports(fs.readFileSync(path.join(REPO, f.from), 'utf8')) })) };
96
+ }
97
+
98
+ const summaryLine = (it, site) => `- [${it.title}](${site}/r/${it.id}.json): ${it.summary}${it.tags?.length ? ` _(${it.tags.filter((t) => t !== it.type).join(', ')})_` : ''}`;
99
+
100
+ export function llmsTxt(items, site, { full = false } = {}) {
101
+ const head = `# paper-motion
102
+
103
+ > Motion resources for AI agents: component transitions, transitions, text effects, camera moves, backgrounds
104
+ > (Paper shaders), palettes, font pairs and video recipes, each with a name. You pick them for the user's brand,
105
+ > propose options as a **board** link they open in a browser, install what they choose as code they own, and render
106
+ > the video with the paper-motion engine. Nothing here renders on a server: every preview plays in the user's browser.
107
+
108
+ ## The flow (for agents)
109
+
110
+ 1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
111
+ ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`.
112
+ 2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
113
+ line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
114
+ "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
115
+ Mirror it back before proposing anything.
116
+ 3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
117
+ this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
118
+ (format below). The user opens it, sees the options live in their look, may tune one, and pastes back one line:
119
+ \`paper-motion: B · mesh-sage --set speed=0.8 (bg-1)\`, or \`paper-motion: none of bg-1, calmer ones\`. The reply
120
+ carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what they saw.
121
+ 4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
122
+ \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
123
+ fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
124
+ whose prompt carries the whole \`pamo add\` command.
125
+ 5. **Make the video.** Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
126
+ \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
127
+
128
+ ## Choosing well: the metadata
129
+
130
+ ${site}/registry.json is the index; every item carries, beside id, type, title, summary, tags and the names of its
131
+ \`params\` (ranges and defaults are in ${site}/r/<id>.json, fetch it before writing a \`--set\`):
132
+ - \`when\`: 2–3 "use it when…" phrases.
133
+ - \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
134
+ - Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
135
+ - Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
136
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
137
+
138
+ \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
139
+ sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
140
+ ground, figure and accent so resources with white type are not punished); \`pamo search --type component --mood calm
141
+ --energy "<=3" --role swap\`.
142
+
143
+ Worked example, four backgrounds for a dark cobalt brand: filter \`type=background\`, \`mode=dark\`; rank by nearest
144
+ \`colors\` to the brand's ground, figure and accent; spread the \`motion\` as far as the catalog allows (a quiet one,
145
+ a lively one); prefer
146
+ \`mood\` overlapping the brand's words; tune one toward the accent (\`dither-slate:colorFront=#3d6bff\`); write a
147
+ one-line \`--note\` for each; share the link.
148
+
149
+ ## The board link format
150
+
151
+ \`${site}/b#<payload>\` where payload = base64url(deflate-raw(JSON)). The JSON:
152
+
153
+ \`\`\`json
154
+ { "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
155
+ "reply": "bg-1",
156
+ "look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
157
+ "options": [
158
+ { "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
159
+ { "label": "B", "id": "neuro-slate", "values": {}, "note": "a technical web" },
160
+ { "label": "C", "id": "dither-slate", "values": { "colorFront": "#3d6bff" }, "note": "pixels in your accent" },
161
+ { "label": "D", "id": "grain-slate", "values": {} } ] }
162
+ \`\`\`
163
+
164
+ Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
165
+ \`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
166
+ defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
167
+
168
+ Resources by type (${items.length} in all):
169
+ `;
170
+ const sections = TYPES.filter((t) => items.some((i) => i.type === t)).map((t) => {
171
+ const list = items.filter((i) => i.type === t);
172
+ if (!full) return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => summaryLine(i, site)).join('\n')}\n`;
173
+ return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => [
174
+ `### ${i.title} · \`${i.id}\``, '', i.summary, '',
175
+ i.signature ? `Signature: \`${i.signature}\`` : '',
176
+ i.params?.length ? `Params: ${i.params.map((p) => `\`${p.name}\`${p.default !== undefined ? ` = ${JSON.stringify(p.default)}` : ''}${p.description ? ` (${p.description})` : ''}`).join('; ')}` : '',
177
+ i.notes?.length ? i.notes.map((n) => `- ${n}`).join('\n') : '',
178
+ `Install: \`npx paper-motion add ${i.id}\` · ${site}/r/${i.id}.json`, '',
179
+ '```js', i.usage, '```', '',
180
+ ].filter((x) => x !== '').join('\n')).join('\n\n')}\n`;
181
+ });
182
+ return head + sections.join('');
183
+ }
184
+
185
+ /**
186
+ * The site's live player: the moves, kit and core (browser-safe), the demo harness with its imports pointed at them,
187
+ * its stylesheet scoped to `.pm-play`, and its fonts. The site imports them at runtime from /play/.
188
+ */
189
+ function publishPlayer(dir, out) {
190
+ const RT = path.join(ENGINE, 'runtime'), demo = path.join(dir, '_demo');
191
+ fs.rmSync(out, { recursive: true, force: true });
192
+ fs.mkdirSync(path.join(out, 'engine', 'moves'), { recursive: true });
193
+ for (const f of ['core.js', 'kit.js', 'moves.js']) fs.copyFileSync(path.join(RT, f), path.join(out, 'engine', f));
194
+ for (const f of fs.readdirSync(path.join(RT, 'moves'))) fs.copyFileSync(path.join(RT, 'moves', f), path.join(out, 'engine', 'moves', f));
195
+ const js = fs.readFileSync(path.join(demo, 'demos.js'), 'utf8')
196
+ .replace(/import \{ scene, ([^}]*) \} from 'paper-motion';/, "import { $1 } from './engine/core.js';\nconst scene = () => { throw new Error('the site mounts demos with mount()'); };")
197
+ .replace(/from 'paper-motion\/moves'/g, "from './engine/moves.js'");
198
+ fs.writeFileSync(path.join(out, 'demos.js'), js);
199
+ 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(', ')} {`);
200
+ fs.writeFileSync(path.join(out, 'demo.css'), css);
201
+ fs.mkdirSync(path.join(out, 'fonts'), { recursive: true });
202
+ for (const f of fs.readdirSync(path.join(demo, 'fonts'))) fs.copyFileSync(path.join(demo, 'fonts', f), path.join(out, 'fonts', f));
203
+ }
204
+
205
+ /** Writes registry.json, r/<id>.json, schema.json, llms.txt and llms-full.txt into `out`. */
206
+ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
207
+ const { items, problems } = validate(dir);
208
+ if (problems.length) { const e = new Error(`registry: ${problems.length} problem(s)\n ${problems.join('\n ')}`); e.problems = problems; throw e; }
209
+ fs.mkdirSync(path.join(out, 'r'), { recursive: true });
210
+ // publish only the preview files that were rendered: a background plays live and may have none
211
+ for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => fs.existsSync(path.join(out, p)))); it.preview = Object.keys(have).length ? have : undefined; }
212
+ const index = {
213
+ name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
214
+ 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)) })),
215
+ };
216
+ fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
217
+ fs.mkdirSync(path.join(out, 'files'), { recursive: true });
218
+ for (const it of items) {
219
+ fs.writeFileSync(path.join(out, 'r', `${it.id}.json`), JSON.stringify(resolveItem(it, site), null, 2));
220
+ 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)));
221
+ }
222
+ fs.copyFileSync(path.join(dir, 'schema.json'), path.join(out, 'schema.json'));
223
+ publishPlayer(dir, path.join(out, 'play'));
224
+ fs.writeFileSync(path.join(out, 'llms.txt'), llmsTxt(items, site));
225
+ fs.writeFileSync(path.join(out, 'llms-full.txt'), llmsTxt(items, site, { full: true }));
226
+ return { count: items.length, out };
227
+ }
228
+
229
+ // ── installing ──────────────────────────────────────────────────
230
+
231
+ async function fetchJson(url) {
232
+ const r = await fetch(url);
233
+ if (!r.ok) throw new Error(`${url}: ${r.status} ${r.statusText}`);
234
+ return r.json();
235
+ }
236
+
237
+ /** One resolved resource (with file contents) from a local folder or the site. */
238
+ export async function getItem(id, reg = resolveRegistry()) {
239
+ if (reg.dir) {
240
+ const it = loadItems(reg.dir).find((i) => i.id === id);
241
+ if (!it) throw new Error(`no resource "${id}" in ${reg.dir} (try \`pamo search\`)`);
242
+ return resolveItem(it);
243
+ }
244
+ return fetchJson(`${reg.url}/r/${id}.json`);
245
+ }
246
+
247
+ export async function getIndex(reg = resolveRegistry()) {
248
+ if (reg.dir) return { items: loadItems(reg.dir).map(({ _dir, ...i }) => i) };
249
+ return fetchJson(`${reg.url}/registry.json`);
250
+ }
251
+
252
+ const readJson = (f, fallback) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : fallback);
253
+ const writeJson = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n'); };
254
+
255
+ /** Deep-merges `frag` into `base`; keys that already hold a different value are reported, and replaced only with force. */
256
+ function mergeInto(base, frag, force, at = '', conflicts = []) {
257
+ for (const [k, v] of Object.entries(frag)) {
258
+ const key = at ? `${at}.${k}` : k;
259
+ 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);
260
+ else if (base[k] !== undefined && JSON.stringify(base[k]) !== JSON.stringify(v)) { conflicts.push(key); if (force) base[k] = v; }
261
+ else base[k] = v;
262
+ }
263
+ return conflicts;
264
+ }
265
+
266
+ /**
267
+ * Installs resources into the project at `cwd`, resolving `requires.items` first. Returns a report per resource.
268
+ * Never overwrites a changed file or a different brand value unless `force`.
269
+ */
270
+ export async function add(ids, { cwd = process.cwd(), registry, force = false, name, set = [] } = {}) {
271
+ const reg = resolveRegistry(registry), done = new Map(), report = [];
272
+ const lockFile = path.join(cwd, 'paper-motion.json'), lock = readJson(lockFile, { registry: reg.url ?? 'local', items: {} });
273
+ async function one(id) {
274
+ if (done.has(id)) return;
275
+ done.set(id, true);
276
+ let it = await getItem(id, reg);
277
+ for (const dep of it.requires?.items ?? []) await one(dep);
278
+ // --set key=value (or --set id:key=value with several ids): the same output the site's code tabs show
279
+ 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));
280
+ if (pairs.length) it = { ...it, ...applyValues(it, parseSet(it, pairs)) };
281
+ const r = { id, type: it.type, wrote: [], skipped: [], conflicts: [], usage: it.usage, notes: it.notes ?? [] };
282
+ const kind = it.install?.kind ?? 'code';
283
+ const base = kind === 'scene' ? path.join('pieces', name ?? it.id) : '';
284
+ for (const f of it.files ?? []) {
285
+ const rel = kind === 'scene' ? path.join(base, path.basename(f.path)) : f.path, dest = path.join(cwd, rel);
286
+ if (f.content === undefined) {
287
+ // a binary asset: kept if present, else copied from the local registry or downloaded from the site
288
+ if (fs.existsSync(dest) && !force) continue;
289
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
290
+ if (reg.dir && f.from && fs.existsSync(path.join(REPO, f.from))) fs.copyFileSync(path.join(REPO, f.from), dest);
291
+ 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())); }
292
+ r.wrote.push(rel); continue;
293
+ }
294
+ if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') !== f.content && !force) { r.skipped.push(rel); continue; }
295
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
296
+ fs.writeFileSync(dest, f.content);
297
+ r.wrote.push(rel);
298
+ }
299
+ if (kind === 'shader-set') {
300
+ const f = path.join(cwd, 'brand/shader-set/set.json'), set = readJson(f, { format: { width: 1920, height: 1080, duration: 6, fps: 30 }, entries: [] });
301
+ set.entries ??= [];
302
+ const entry = { id: it.id, source: 'paper-motion.app', ...it.install.entry }, at = set.entries.findIndex((e) => e.id === it.id);
303
+ if (at < 0) set.entries.push(entry);
304
+ 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`); }
305
+ writeJson(f, set); r.wrote.push('brand/shader-set/set.json');
306
+ }
307
+ if (kind === 'tokens') {
308
+ const f = path.join(cwd, 'brand/tokens.json'), tokens = readJson(f, {});
309
+ r.conflicts.push(...mergeInto(tokens, it.install.entry ?? {}, force).map((k) => `brand/tokens.json ${k}`));
310
+ writeJson(f, tokens); r.wrote.push('brand/tokens.json');
311
+ }
312
+ lock.items[it.id] = it.version;
313
+ report.push(r);
314
+ }
315
+ for (const id of ids) await one(id);
316
+ writeJson(lockFile, lock);
317
+ return report;
318
+ }
319
+
320
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
321
+ /** Distance between two hex colors (0 = same, ~441 = black to white). */
322
+ 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]); };
323
+ /** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
324
+ export const colorFit = (item, brand) => {
325
+ if (!item.colors?.length || !brand.length) return null;
326
+ const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
327
+ // a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
328
+ return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
329
+ };
330
+
331
+ /**
332
+ * Finds resources: free words over id/title/summary/tags/when, plus filters: type, mode (light|dark), mood,
333
+ * motion (still|gentle|lively), role, energy ("3", "2-4", "<=3"), near (hex colors: sorts by closeness).
334
+ */
335
+ export async function search(query, opts = {}) {
336
+ const { items } = await getIndex(resolveRegistry(opts.registry));
337
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
338
+ 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)); };
339
+ 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; };
340
+ const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
341
+ 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));
342
+ if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
343
+ return out;
344
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,193 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { launch, openScene } from './browser.mjs';
6
+ import { synthesize, writeWav } from './sfx.mjs';
7
+
8
+ function ffmpeg(args, { stdin = false } = {}) {
9
+ const p = spawn(process.env.PAPER_MOTION_FFMPEG || 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], { stdio: [stdin ? 'pipe' : 'ignore', 'inherit', 'pipe'] });
10
+ let err = '';
11
+ p.stderr.on('data', (d) => { err += d; });
12
+ if (stdin) p.stdin.on('error', () => {}); // surfaced through p.done instead
13
+ p.done = new Promise((res, rej) => {
14
+ p.on('error', (e) => rej(e.code === 'ENOENT' ? new Error('ffmpeg not found. Install it (macOS: brew install ffmpeg · Ubuntu: apt install ffmpeg · Windows: winget install ffmpeg)') : e));
15
+ p.on('close', (code) => (code === 0 ? res() : rej(new Error(`ffmpeg failed (${code}): ${err.trim()}`))));
16
+ });
17
+ return p;
18
+ }
19
+
20
+ const write = (proc, buf) => {
21
+ if (proc.stdin.write(buf)) return Promise.resolve();
22
+ return Promise.race([
23
+ new Promise((res) => proc.stdin.once('drain', res)),
24
+ proc.done.then(() => { throw new Error('ffmpeg exited early'); }),
25
+ ]);
26
+ };
27
+
28
+ /** Render stills at given times; returns file paths. Optionally a contact sheet. */
29
+ export async function renderStills({ url, times, count, outDir, dpr = 1, sheet = true, motionBlur = 1, shutter = 180 }) {
30
+ fs.mkdirSync(outDir, { recursive: true });
31
+ const browser = await launch();
32
+ try {
33
+ const s = await openScene(browser, url, { dpr });
34
+ const { duration, fps } = s.config;
35
+ if (!times?.length) {
36
+ const n = count ?? 6;
37
+ times = Array.from({ length: n }, (_, i) => (n === 1 ? 0 : (i * duration) / n));
38
+ }
39
+ const files = [];
40
+ for (const t of times) {
41
+ const tt = Math.round(Math.min(t, duration - 1 / fps) * fps) / fps;
42
+ const f = path.join(outDir, `t${tt.toFixed(2).padStart(6, '0')}.png`);
43
+ const k = Math.max(1, Math.round(motionBlur));
44
+ if (k === 1) { await s.seek(tt); fs.writeFileSync(f, await s.capture()); }
45
+ else {
46
+ const p = ffmpeg(['-f', 'image2pipe', '-framerate', String(k), '-c:v', 'png', '-i', '-', '-vf', `tmix=frames=${k},select='eq(n\\,${k - 1})'`, '-frames:v', '1', f], { stdin: true });
47
+ for (const st of subframes(Math.round(tt * fps), fps, k, shutter, duration)) { await s.seek(st); await write(p, await s.capture()); }
48
+ p.stdin.end(); await p.done;
49
+ }
50
+ files.push(f);
51
+ }
52
+ let sheetPath = null;
53
+ if (sheet && files.length > 1) {
54
+ sheetPath = path.join(outDir, 'contact-sheet.jpg');
55
+ const cols = files.length <= 4 ? files.length : Math.ceil(Math.sqrt(files.length));
56
+ const gap = 8;
57
+ const w = Math.min(640, s.config.width);
58
+ const h = Math.round((w * s.config.height) / s.config.width / 2) * 2;
59
+ const inputs = files.flatMap((f) => ['-i', f]);
60
+ const scaled = files.map((_, i) => `[${i}:v]scale=${w}:${h}:flags=lanczos[v${i}]`).join(';');
61
+ const stack = files.map((_, i) => `[v${i}]`).join('');
62
+ const layout = files.map((_, i) => `${(i % cols) * (w + gap)}_${Math.floor(i / cols) * (h + gap)}`).join('|');
63
+ const filter = `${scaled};${stack}xstack=inputs=${files.length}:layout=${layout}:fill=0x111111[out]`;
64
+ const p = ffmpeg([...inputs, '-filter_complex', filter, '-map', '[out]', '-q:v', '3', sheetPath]);
65
+ await p.done;
66
+ }
67
+ await s.close();
68
+ return { files, sheet: sheetPath, config: s.config, times };
69
+ } finally {
70
+ await browser.close();
71
+ }
72
+ }
73
+
74
+ const FORMATS = {
75
+ mp4: { ext: 'mp4', args: (o) => ['-c:v', 'libx264', '-preset', 'slow', '-crf', String(o.crf ?? 16), '-x264-params', 'aq-mode=3', '-pix_fmt', 'yuv420p', ...bt709, '-movflags', '+faststart'], vf: yuv },
76
+ hevc: { ext: 'mp4', args: (o) => ['-c:v', 'libx265', '-preset', 'slow', '-crf', String(o.crf ?? 18), '-pix_fmt', 'yuv420p10le', '-tag:v', 'hvc1', ...bt709, '-movflags', '+faststart'], vf: yuv },
77
+ prores: { ext: 'mov', args: () => ['-c:v', 'prores_ks', '-profile:v', '3', '-pix_fmt', 'yuv422p10le', '-vendor', 'apl0', ...bt709], vf: yuv },
78
+ webm: { ext: 'webm', args: (o) => ['-c:v', 'libvpx-vp9', '-crf', String(o.crf ?? 20), '-b:v', '0', '-row-mt', '1', '-pix_fmt', 'yuv420p', ...bt709], vf: yuv },
79
+ gif: { ext: 'gif', args: () => [], gif: true },
80
+ png: { ext: 'png', args: () => [], seq: true },
81
+ };
82
+ const bt709 = ['-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709'];
83
+ function yuv() { return 'scale=out_color_matrix=bt709:out_range=tv'; }
84
+
85
+ /** Render a scene to video. */
86
+ /** Synthesize the scene's sfx cues to a WAV file. Returns the path, or null when there are none. */
87
+ export async function renderSfx({ url, out, from = 0, to }) {
88
+ const browser = await launch();
89
+ try {
90
+ const s = await openScene(browser, url);
91
+ const r = await sfxFromPage(s, { from, to, out });
92
+ await s.close();
93
+ return r;
94
+ } finally { await browser.close(); }
95
+ }
96
+
97
+ async function sfxFromPage(s, { from = 0, to, out, normalize = true }) {
98
+ const { cues, audio } = await s.page.evaluate(() => ({ cues: window.__pm.cues ?? [], audio: window.__pm.audio ?? {} }));
99
+ if (!cues.length) return null;
100
+ const end = to ?? s.config.duration;
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, normalize }));
103
+ return { out, cues: cues.length };
104
+ }
105
+
106
+ /** Subframe times for frame f: k samples spread over the shutter, centered on the frame (After Effects-style). */
107
+ function subframes(f, fps, k, shutter, duration) {
108
+ if (k <= 1) return [f / fps];
109
+ const open = shutter / 360;
110
+ return Array.from({ length: k }, (_, j) => Math.min(duration - 1e-4, Math.max(0, (f + ((j + 0.5) / k - 0.5) * open) / fps)));
111
+ }
112
+
113
+ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1, from, to, fps: fpsOverride, crf, audio, sfx = true, motionBlur = 1, shutter = 180, gifWidth = 720, gifFps = 24, onProgress }) {
114
+ const fmt = FORMATS[format];
115
+ if (!fmt) throw new Error(`Unknown format "${format}". Use one of: ${Object.keys(FORMATS).join(', ')}`);
116
+ const browser = await launch();
117
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'paper-motion-'));
118
+ const t0 = Date.now();
119
+ try {
120
+ const first = await openScene(browser, url, { dpr });
121
+ const cfg = first.config;
122
+ const fps = fpsOverride ?? cfg.fps;
123
+ const f0 = Math.round((from ?? 0) * fps);
124
+ const f1 = Math.round((to ?? cfg.duration) * fps);
125
+ const total = f1 - f0;
126
+ if (total <= 0) throw new Error('Nothing to render (check --from/--to).');
127
+ const n = Math.max(1, Math.min(workers ?? Math.min(4, Math.max(1, Math.floor(os.cpus().length / 2))), Math.ceil(total / 15)));
128
+ const chunk = Math.ceil(total / n);
129
+ let done = 0;
130
+ const tick = () => onProgress?.({ done, total, elapsed: (Date.now() - t0) / 1000 });
131
+
132
+ let sfxWav = null;
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'), normalize: !audio }); // with an external track, effects keep their own gains
135
+ sfxWav = r?.out ?? null;
136
+ }
137
+
138
+ const sessions = [first];
139
+ for (let i = 1; i < n; i++) sessions.push(await openScene(browser, url, { dpr }));
140
+
141
+ const segments = await Promise.all(sessions.map(async (s, i) => {
142
+ const a = f0 + i * chunk, b = Math.min(f1, a + chunk);
143
+ if (a >= b) return null;
144
+ const seg = path.join(tmp, `seg${String(i).padStart(3, '0')}.mkv`);
145
+ // Lossless RGB intermediate; the final encode happens once, after concat.
146
+ const k = Math.max(1, Math.round(motionBlur));
147
+ // Motion blur: k subframes per frame are averaged (tmix) and one frame is kept per group.
148
+ const blur = k > 1 ? ['-vf', `tmix=frames=${k},select='eq(mod(n\\,${k})\\,${k - 1})',setpts=N/(${fps}*TB)`, '-r', String(fps)] : [];
149
+ const p = ffmpeg(['-f', 'image2pipe', '-framerate', String(fps * k), '-c:v', 'png', '-i', '-', ...blur, '-c:v', 'libx264rgb', '-preset', 'ultrafast', '-crf', '0', seg], { stdin: true });
150
+ for (let f = a; f < b; f++) {
151
+ for (const tt of subframes(f, fps, k, shutter, cfg.duration)) {
152
+ await s.seek(tt);
153
+ await write(p, await s.capture());
154
+ }
155
+ done++; tick();
156
+ }
157
+ p.stdin.end();
158
+ await p.done;
159
+ await s.close();
160
+ return seg;
161
+ }));
162
+
163
+ const list = path.join(tmp, 'list.txt');
164
+ fs.writeFileSync(list, segments.filter(Boolean).map((s) => `file '${s.replace(/'/g, "'\\''")}'`).join('\n'));
165
+ const W = cfg.width, H = cfg.height;
166
+ const scale = dpr !== 1 ? `scale=${W}:${H}:flags=lanczos` : null;
167
+ out = out ?? `render.${fmt.ext}`;
168
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
169
+ const input = ['-f', 'concat', '-safe', '0', '-i', list];
170
+
171
+ if (fmt.seq) {
172
+ const dir = out.replace(/\.png$/, '');
173
+ fs.mkdirSync(dir, { recursive: true });
174
+ await ffmpeg([...input, ...(scale ? ['-vf', scale] : []), path.join(dir, 'frame_%05d.png')]).done;
175
+ out = dir;
176
+ } else if (fmt.gif) {
177
+ const vf = `fps=${gifFps},scale=${gifWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=256:stats_mode=diff[p];[b][p]paletteuse=dither=sierra2_4a`;
178
+ await ffmpeg([...input, '-vf', vf, '-loop', '0', out]).done;
179
+ } else {
180
+ const vf = [scale, fmt.vf?.()].filter(Boolean).join(',');
181
+ const codec = ['-c:a', format === 'webm' ? 'libopus' : 'aac', '-b:a', '320k'];
182
+ const audios = [audio, sfxWav].filter(Boolean);
183
+ const audioArgs = audios.length === 0 ? []
184
+ : audios.length === 1 ? ['-i', audios[0], '-map', '0:v', '-map', '1:a', ...codec, '-shortest']
185
+ : ['-i', audios[0], '-i', audios[1], '-filter_complex', '[1:a][2:a]amix=inputs=2:normalize=0[a]', '-map', '0:v', '-map', '[a]', ...codec, '-shortest'];
186
+ await ffmpeg([...input, ...audioArgs, ...(vf ? ['-vf', vf] : []), '-r', String(fps), ...fmt.args({ crf }), out]).done;
187
+ }
188
+ return { out, frames: total, fps, seconds: (Date.now() - t0) / 1000, workers: n, config: cfg };
189
+ } finally {
190
+ await browser.close();
191
+ fs.rmSync(tmp, { recursive: true, force: true });
192
+ }
193
+ }