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.
- package/README.md +14 -0
- package/bin/pamo.mjs +134 -4
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/lib/customize.mjs +162 -0
- package/lib/live-board.js +128 -0
- package/lib/registry.mjs +363 -0
- package/lib/render.mjs +3 -3
- package/lib/server.mjs +1 -0
- package/lib/sfx.mjs +5 -3
- package/package.json +1 -1
- package/runtime/core.js +269 -0
- package/runtime/index.js +3 -260
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +66 -146
- package/templates/move-demo/scene.html +4 -3
package/lib/registry.mjs
ADDED
|
@@ -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;
|