paper-motion 0.1.0 → 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.
- package/README.md +14 -0
- package/bin/pamo.mjs +133 -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 +344 -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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Customizing a resource: the code for a set of param values. One module for both sides, so what the site shows
|
|
2
|
+
// (its code tabs, "Copy for your agent") and what `pamo add <id> --set k=v` installs are always the same.
|
|
3
|
+
// Plain ESM with no Node imports: the site copies this file and runs it in the browser.
|
|
4
|
+
|
|
5
|
+
export const MOVE_TYPES = ['component', 'transition', 'text', 'camera'];
|
|
6
|
+
|
|
7
|
+
/** Param defaults by name. */
|
|
8
|
+
export const defaults = (item) => Object.fromEntries((item.params ?? []).map((p) => [p.name, p.default]));
|
|
9
|
+
|
|
10
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
11
|
+
/** Params whose value differs from the default. */
|
|
12
|
+
export const changed = (item, values) => (item.params ?? []).filter((p) => p.name in values && !same(values[p.name], p.default));
|
|
13
|
+
|
|
14
|
+
/** A JS literal: strings in single quotes, `ease.*` left as code, arrays and objects inline. */
|
|
15
|
+
export function literal(v) {
|
|
16
|
+
if (typeof v === 'string') return /^ease\.\w+$/.test(v) ? v : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
17
|
+
if (Array.isArray(v)) return `[${v.map(literal).join(', ')}]`;
|
|
18
|
+
if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x)}`).join(', ')} }`;
|
|
19
|
+
return String(v);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `--set key=value` arguments, shell-safe. */
|
|
23
|
+
export function setArgs(item, values) {
|
|
24
|
+
return changed(item, values).map((p) => {
|
|
25
|
+
const v = values[p.name], raw = typeof v === 'string' ? v : JSON.stringify(v);
|
|
26
|
+
const arg = `${p.name}=${raw}`;
|
|
27
|
+
return /^[\w.=#,:/@+-]+$/.test(arg) ? `--set ${arg}` : `--set '${arg.replace(/'/g, "'\\''")}'`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses `key=value` pairs (from the CLI) into values, JSON where it parses, strings otherwise. */
|
|
32
|
+
export function parseSet(item, pairs) {
|
|
33
|
+
const out = defaults(item);
|
|
34
|
+
for (const pair of pairs) {
|
|
35
|
+
const i = pair.indexOf('=');
|
|
36
|
+
if (i < 0) throw new Error(`--set needs key=value, got "${pair}"`);
|
|
37
|
+
const k = pair.slice(0, i), raw = pair.slice(i + 1);
|
|
38
|
+
if (!(item.params ?? []).some((p) => p.name === k)) throw new Error(`"${item.id}" has no param "${k}" (params: ${(item.params ?? []).map((p) => p.name).join(', ')})`);
|
|
39
|
+
try { out[k] = JSON.parse(raw); } catch { out[k] = raw; }
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── per type ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const gf = (family, weights) => `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, '+')}${weights?.length ? `:wght@${[...new Set(weights)].sort((a, b) => a - b).join(';')}` : ''}&display=swap`;
|
|
47
|
+
|
|
48
|
+
/** A move file with the values written in as its defaults (only inside the exported function's options). */
|
|
49
|
+
function moveFile(item, content, values) {
|
|
50
|
+
const name = item.export, start = content.indexOf(`export function ${name}(`);
|
|
51
|
+
if (start < 0) return content;
|
|
52
|
+
const end = content.indexOf(') {', start);
|
|
53
|
+
let sig = content.slice(start, end);
|
|
54
|
+
for (const p of changed(item, values)) sig = sig.replace(new RegExp(`(\\b${p.name}\\s*=\\s*)(\\[[^\\]]*\\]|'[^']*'|"[^"]*"|[^,}]+?)(\\s*)(?=[,}])`), `$1${literal(values[p.name])}$3`);
|
|
55
|
+
return content.slice(0, start) + sig + content.slice(end);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function backgroundEntry(item, values) {
|
|
59
|
+
const e = item.install.entry ?? {};
|
|
60
|
+
return { ...e, props: { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function paletteTokens(item, values) {
|
|
64
|
+
const e = item.install.entry ?? {};
|
|
65
|
+
const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
|
|
66
|
+
return { ...e, colors, ramp: values.ramp ?? e.ramp };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fontTokens(item, values) {
|
|
70
|
+
const e = item.install.entry ?? {};
|
|
71
|
+
const f = e.fonts ?? {}, w = values.displayWeight ?? e.type?.display?.weight ?? 400, tr = `${values.tracking ?? parseFloat(e.type?.display?.tracking ?? '0')}em`;
|
|
72
|
+
const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; return { ...f[role], family, weights, css: gf(family, weights) }; };
|
|
73
|
+
const fonts = { display: pick('display', [w]), text: pick('text', f.text?.weights ?? [400, 500]), mono: pick('mono', f.mono?.weights ?? [400]) };
|
|
74
|
+
const type = { ...e.type, display: { ...e.type?.display, weight: w, tracking: tr }, title: { ...e.type?.title, weight: w, tracking: tr } };
|
|
75
|
+
return { fonts, type };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A recipe's CONFIG object from flat values (proof1Title… → proof[0].title, "1920x1080" → [1920, 1080]). */
|
|
79
|
+
function recipeConfig(values) {
|
|
80
|
+
const cfg = {}, groups = {};
|
|
81
|
+
for (const [k, v] of Object.entries(values)) {
|
|
82
|
+
const m = k.match(/^(proof|step)(\d)(Label|Title|Body)$/);
|
|
83
|
+
if (m) { const g = m[1] === 'proof' ? 'proof' : 'steps'; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
|
|
84
|
+
if (k === 'format') cfg.format = String(v).split('x').map(Number);
|
|
85
|
+
else cfg[k] = v === '' ? null : v;
|
|
86
|
+
}
|
|
87
|
+
return { ...cfg, ...groups };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recipeScene(content, values) {
|
|
91
|
+
const cfg = recipeConfig(values);
|
|
92
|
+
const start = content.indexOf('const CONFIG = {');
|
|
93
|
+
if (start < 0) return content;
|
|
94
|
+
const end = content.indexOf('\n};', start) + 3;
|
|
95
|
+
const body = Object.entries(cfg).map(([k, v]) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
|
|
96
|
+
? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
|
|
97
|
+
: ` ${k}: ${v === null ? 'null' : literal(v)},`)).join('\n');
|
|
98
|
+
return `${content.slice(0, start)}const CONFIG = {\n${body}\n};${content.slice(end)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── the public API ──────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Applies values to a resolved resource (files with content): returns the files and install entry that
|
|
105
|
+
* `pamo add --set` writes, the same ones the site's File / entry tabs show.
|
|
106
|
+
*/
|
|
107
|
+
export function apply(item, values) {
|
|
108
|
+
const v = { ...defaults(item), ...values };
|
|
109
|
+
const files = (item.files ?? []).map((f) => {
|
|
110
|
+
if (f.content === undefined) return f;
|
|
111
|
+
if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
|
|
112
|
+
if (item.type === 'recipe' && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
|
|
113
|
+
return f;
|
|
114
|
+
});
|
|
115
|
+
let entry = item.install?.entry;
|
|
116
|
+
if (item.type === 'background') entry = backgroundEntry(item, v);
|
|
117
|
+
if (item.type === 'palette') entry = paletteTokens(item, v);
|
|
118
|
+
if (item.type === 'font-pair') entry = fontTokens(item, v);
|
|
119
|
+
return { files, install: { ...item.install, ...(entry ? { entry } : {}) } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The code tabs, the change count and the agent prompt for a resource at these values. */
|
|
123
|
+
export function code(item, values, { showAll = false, site = 'https://paper-motion.app' } = {}) {
|
|
124
|
+
const v = { ...defaults(item), ...values }, diff = changed(item, v), out = apply(item, v);
|
|
125
|
+
const args = setArgs(item, v);
|
|
126
|
+
const agent = `Use the paper-motion resource "${item.id}" (${site}/r/${item.id}.json) in my video. Install it with: npx paper-motion add ${item.id}${args.length ? ` ${args.join(' ')}` : ''}`;
|
|
127
|
+
let tabs = [];
|
|
128
|
+
if (MOVE_TYPES.includes(item.type)) {
|
|
129
|
+
const listed = showAll ? (item.params ?? []).filter((p) => !p.hidden) : diff;
|
|
130
|
+
const head = ((item.signature ?? '').match(/\(([^{)]*)/)?.[1] ?? 'a, b, p').replace(/,\s*$/, '').trim();
|
|
131
|
+
const file = out.files.find((f) => f.content !== undefined);
|
|
132
|
+
const opts = listed.length ? `, { ${listed.map((p) => `${p.name}: ${literal(v[p.name])}`).join(', ')} }` : '';
|
|
133
|
+
tabs = [
|
|
134
|
+
{ key: 'usage', label: 'Usage', lang: 'js', code: `import { ${item.export} } from './${file?.path ?? `moves/${item.id}.js`}';\n\n// in s.update(t)\n${item.export}(${head}${opts});` },
|
|
135
|
+
{ key: 'file', label: 'File', lang: 'js', code: file?.content ?? '' },
|
|
136
|
+
];
|
|
137
|
+
} else if (item.type === 'background') {
|
|
138
|
+
const e = out.install.entry, shader = e.shader;
|
|
139
|
+
tabs = [
|
|
140
|
+
{ key: 'entry', label: 'set.json entry', lang: 'json', code: JSON.stringify({ id: item.id, ...e }, null, 2) },
|
|
141
|
+
{ key: 'usage', label: 'Usage', lang: 'js', code: `import { loadBrand } from 'paper-motion/brand';\n\nconst B = await loadBrand();\nB.shader(s, '#bg', '${item.id}');` },
|
|
142
|
+
{ key: 'react', label: 'Paper React', lang: 'jsx', code: `import { ${shader} } from '@paper-design/shaders-react';\n\n<${shader}\n${Object.entries(e.props).map(([k, x]) => ` ${k}={${typeof x === 'string' ? JSON.stringify(x) : JSON.stringify(x)}}`).join('\n')}\n style={{ width: '100%', height: '100%' }}\n/>` },
|
|
143
|
+
];
|
|
144
|
+
} else if (item.type === 'palette') {
|
|
145
|
+
const e = out.install.entry;
|
|
146
|
+
tabs = [
|
|
147
|
+
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify({ colors: e.colors, ramp: e.ramp, mode: e.mode }, null, 2) },
|
|
148
|
+
{ key: 'css', label: 'CSS variables', lang: 'css', code: `:root {\n${Object.entries(e.colors).map(([r, c]) => ` --${r}: ${c.hex};`).join('\n')}\n${(e.ramp ?? []).map((h, i) => ` --ramp-${i + 1}: ${h};`).join('\n')}\n}` },
|
|
149
|
+
];
|
|
150
|
+
} else if (item.type === 'font-pair') {
|
|
151
|
+
const e = out.install.entry, f = e.fonts;
|
|
152
|
+
const cat = (fam) => (/mono|code/i.test(fam) ? 'monospace' : /serif|playfair|fraunces|garamond|newsreader|instrument/i.test(fam) && !/sans/i.test(fam) ? 'serif' : 'sans-serif');
|
|
153
|
+
tabs = [
|
|
154
|
+
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
|
|
155
|
+
{ key: 'css', label: 'CSS variables', lang: 'css', code: `${Object.values(f).map((x) => `@import url('${x.css}');`).filter((x, i, a) => a.indexOf(x) === i).join('\n')}\n\n:root {\n${Object.entries(f).map(([r, x]) => ` --font-${r}: '${x.family}', ${cat(x.family)};`).join('\n')}\n --display-weight: ${e.type.display.weight};\n --display-tracking: ${e.type.display.tracking};\n}` },
|
|
156
|
+
];
|
|
157
|
+
} else if (item.type === 'recipe') {
|
|
158
|
+
const scene = out.files.find((f) => /scene\.html$/.test(f.path));
|
|
159
|
+
tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
|
|
160
|
+
}
|
|
161
|
+
return { tabs, changed: diff.length, agent, setArgs: args };
|
|
162
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A board: 2–4 labelled options the agent proposes, carried in a paper-motion.app link so the user can see them
|
|
2
|
+
// live in their own look, tune one and choose. Nothing reaches a server: the payload travels in the URL fragment.
|
|
3
|
+
// https://paper-motion.app/b#<base64url(deflate-raw(JSON))>
|
|
4
|
+
// This module runs in Node (`pamo board --url`) and in the browser (the /b page), so it has no Node imports: it
|
|
5
|
+
// picks zlib or CompressionStream at call time. Plain ESM; the site copies it next to customize.mjs.
|
|
6
|
+
|
|
7
|
+
// (lib/board.mjs is the other kind of board: the rendered one, stills and clips composed by ffmpeg.)
|
|
8
|
+
|
|
9
|
+
export const BOARD_VERSION = 1;
|
|
10
|
+
export const MAX_PAYLOAD = 8 * 1024;
|
|
11
|
+
const LABELS = ['A', 'B', 'C', 'D'];
|
|
12
|
+
const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
|
|
16
|
+
* look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
|
|
17
|
+
* options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string }> }} Board
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
|
|
21
|
+
export function validateBoard(b, index) {
|
|
22
|
+
const errs = [];
|
|
23
|
+
if (!b || typeof b !== 'object') return ['not an object'];
|
|
24
|
+
if (b.v !== BOARD_VERSION) errs.push(`v must be ${BOARD_VERSION}`);
|
|
25
|
+
if (!['resource', 'kit'].includes(b.kind)) errs.push('kind must be "resource" or "kit"');
|
|
26
|
+
if (!b.title || typeof b.title !== 'string') errs.push('title is required');
|
|
27
|
+
if (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4) errs.push('2 to 4 options');
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const [i, o] of (b.options ?? []).entries()) {
|
|
30
|
+
const at = `options[${i}]`;
|
|
31
|
+
if (!LABELS.includes(o.label)) errs.push(`${at}.label must be A–D`);
|
|
32
|
+
if (seen.has(o.label)) errs.push(`${at}.label "${o.label}" repeats`);
|
|
33
|
+
seen.add(o.label);
|
|
34
|
+
if (b.kind === 'resource') {
|
|
35
|
+
if (!o.id) errs.push(`${at}.id is required`);
|
|
36
|
+
else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
|
|
37
|
+
} else {
|
|
38
|
+
if (!Array.isArray(o.items) || !o.items.length) errs.push(`${at}.items is required for a kit board`);
|
|
39
|
+
for (const it of o.items ?? []) if (index && !index[it.id]) errs.push(`${at}: "${it.id}" is not in the registry`);
|
|
40
|
+
}
|
|
41
|
+
if (o.values && typeof o.values !== 'object') errs.push(`${at}.values must be an object`);
|
|
42
|
+
}
|
|
43
|
+
if (b.kind === 'resource' && index) {
|
|
44
|
+
const types = new Set(b.options.map((o) => index[o.id]?.type).filter(Boolean));
|
|
45
|
+
if (types.size > 1) errs.push(`a resource board shows one type at a time (got ${[...types].join(', ')})`);
|
|
46
|
+
}
|
|
47
|
+
for (const k of ['palette', 'font']) { const l = b.look?.[k]; if (l && index && !index[l.id]) errs.push(`look.${k} "${l.id}" is not in the registry`); }
|
|
48
|
+
return errs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── compression, both runtimes ──────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const b64url = {
|
|
54
|
+
enc: (bytes) => (typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64url') : btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')),
|
|
55
|
+
dec: (s) => { const b = s.replace(/-/g, '+').replace(/_/g, '/'); if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b, 'base64')); const bin = atob(b); return Uint8Array.from(bin, (c) => c.charCodeAt(0)); },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function deflate(bytes) {
|
|
59
|
+
if (typeof CompressionStream === 'function') {
|
|
60
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
|
|
61
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
62
|
+
}
|
|
63
|
+
const { deflateRawSync } = await import('node:zlib');
|
|
64
|
+
return new Uint8Array(deflateRawSync(bytes));
|
|
65
|
+
}
|
|
66
|
+
async function inflate(bytes) {
|
|
67
|
+
if (typeof DecompressionStream === 'function') {
|
|
68
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
69
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
70
|
+
}
|
|
71
|
+
const { inflateRawSync } = await import('node:zlib');
|
|
72
|
+
return new Uint8Array(inflateRawSync(bytes));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Board → the fragment string (no leading #). Throws when invalid or over the size budget. */
|
|
76
|
+
export async function encodeBoard(board, index) {
|
|
77
|
+
const errs = validateBoard(board, index);
|
|
78
|
+
if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
|
|
79
|
+
const json = JSON.stringify(board);
|
|
80
|
+
if (json.length > MAX_PAYLOAD) throw new Error(`board: ${json.length} bytes of JSON, the limit is ${MAX_PAYLOAD}`);
|
|
81
|
+
return b64url.enc(await deflate(new TextEncoder().encode(json)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fragment string → board. Throws on a bad or oversized payload. */
|
|
85
|
+
export async function decodeBoard(fragment) {
|
|
86
|
+
const s = fragment.replace(/^#/, '');
|
|
87
|
+
if (!s) throw new Error('empty board');
|
|
88
|
+
if (s.length > MAX_PAYLOAD * 2) throw new Error('board link too long');
|
|
89
|
+
const json = new TextDecoder().decode(await inflate(b64url.dec(s)));
|
|
90
|
+
const board = JSON.parse(json);
|
|
91
|
+
const errs = validateBoard(board);
|
|
92
|
+
if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
|
|
93
|
+
return board;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const boardUrl = (site, fragment) => `${site.replace(/\/$/, '')}/b#${fragment}`;
|
|
97
|
+
|
|
98
|
+
// ── the reply line ──────────────────────────────────────────────
|
|
99
|
+
// What the user pastes back in the chat. One line, starts with "paper-motion:", so an agent can spot and parse it.
|
|
100
|
+
// paper-motion: A · mesh-sage chose A as proposed
|
|
101
|
+
// paper-motion: B · mesh-sage --set speed=0.8 --set colors=[…] chose B, tuned
|
|
102
|
+
// paper-motion: none of bg-1, show me calmer ones none; free text after the comma
|
|
103
|
+
// paper-motion: kit B · cobalt geist mesh-graphite whip-pan a kit board choice
|
|
104
|
+
// `reply` (the agent's name for the decision) is appended as "(bg-1)" when present.
|
|
105
|
+
|
|
106
|
+
export function replyLine(board, choice) {
|
|
107
|
+
const tag = board.reply ? ` (${board.reply})` : '';
|
|
108
|
+
if (choice.none) return `paper-motion: none of ${board.reply ?? `"${board.title}"`}${choice.text ? `, ${choice.text}` : ''}`;
|
|
109
|
+
const o = board.options.find((x) => x.label === choice.label);
|
|
110
|
+
if (!o) throw new Error(`no option ${choice.label}`);
|
|
111
|
+
if (board.kind === 'kit') return `paper-motion: kit ${o.label} · ${o.items.map((i) => i.id).join(' ')}${tag}`;
|
|
112
|
+
const sets = (choice.setArgs ?? []).join(' ');
|
|
113
|
+
return `paper-motion: ${o.label} · ${o.id}${sets ? ` ${sets}` : ''}${tag}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parses a reply line the user pasted. Returns null when the text is not one. */
|
|
117
|
+
export function parseReply(text) {
|
|
118
|
+
const m = String(text).match(/paper-motion:\s*(.+)$/m);
|
|
119
|
+
if (!m) return null;
|
|
120
|
+
const body = m[1].trim(), tag = body.match(/\(([^()]+)\)\s*$/)?.[1] ?? null, core = body.replace(/\s*\([^()]+\)\s*$/, '');
|
|
121
|
+
if (core.startsWith('none of ')) { const rest = core.slice(8); const i = rest.indexOf(','); return { none: true, reply: tag ?? rest.slice(0, i < 0 ? undefined : i).trim(), text: i < 0 ? '' : rest.slice(i + 1).trim() }; }
|
|
122
|
+
const kit = core.match(/^kit ([A-D]) · (.+)$/);
|
|
123
|
+
if (kit) return { kind: 'kit', label: kit[1], ids: kit[2].split(/\s+/), reply: tag };
|
|
124
|
+
const one = core.match(/^([A-D]) · (\S+)(.*)$/);
|
|
125
|
+
if (!one) return null;
|
|
126
|
+
const sets = [...one[3].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]);
|
|
127
|
+
return { kind: 'resource', label: one[1], id: one[2], set: sets, reply: tag };
|
|
128
|
+
}
|
package/lib/registry.mjs
ADDED
|
@@ -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
|
+
}
|