paper-motion 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +6 -2
- package/bin/pamo.mjs +4 -3
- package/lib/customize.mjs +68 -7
- package/lib/live-board.js +1 -1
- package/lib/registry.mjs +44 -6
- package/package.json +3 -2
- package/runtime/brand.js +2 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
|
+
|
|
5
|
+
## 0.2.1 · 2026-11-08
|
|
6
|
+
|
|
7
|
+
Fixes found by making paper-motion's own launch video as a fresh user.
|
|
8
|
+
|
|
9
|
+
- The CLI's default site is `https://paper-motion.vercel.app` until the `paper-motion.app` domain is connected, so `search`, `add` and `board --url` work without `PAPER_MOTION_SITE`.
|
|
10
|
+
- `pamo add` scaffolds what a recipe reads but boards never settle: `brand/motion.json` (pace) and `brand/sound.json` (cues) with defaults when missing, and the brand's name through `--brand "Name"`. Without a name it says so instead of rendering "brand".
|
|
11
|
+
- `llms.txt` step 4 says that `add` never overwrites a value already in the project and that re-choosing needs `--force`.
|
|
12
|
+
- Recipes dim the background layer behind type (`CONFIG.backgroundOpacity`, with a vignette on launch, explainer and end-card) and size card bodies and the action line for phones.
|
|
13
|
+
- Registry items can set `preview.at`, the poster time in seconds; the five recipes use it so their posters land on a readable frame. `registry build` keeps non-string preview fields when it drops missing files.
|
|
14
|
+
|
|
15
|
+
## 0.2.0 · 2026-11-08
|
|
16
|
+
|
|
17
|
+
paper-motion.app: a registry of named resources and the commands that use it.
|
|
18
|
+
|
|
19
|
+
- `pamo registry build` publishes `registry.json`, `r/<id>.json`, `llms.txt` and `llms-full.txt` from `registry/`; `pamo registry previews` renders a clip and a poster per move and recipe, and `build` publishes only previews that exist.
|
|
20
|
+
- `pamo add <id…>` installs resources as code the user owns: moves into `moves/`, backgrounds into `brand/shader-set/set.json`, palettes and font pairs into `brand/tokens.json`, recipes into `pieces/<id>/`. `--set key=value` writes the user's values through the same generator the site uses (`lib/customize.mjs`); `paper-motion.json` records what was installed. `pamo list` and `pamo search` read the index; `search` filters by the choosing metadata (when, mood, energy, role, colors, motion…) and `--near` ranks by color fit.
|
|
21
|
+
- `pamo board --url` builds and validates live board links (`/b#payload`, deflate + base64url, nothing reaches a server): 2–4 labelled options the user compares in their own look, tunes and chooses; the reply is one line the agent parses with `lib/live-board.js`. `lib/board.mjs` remains the rendered board.
|
|
22
|
+
- `paper-motion/moves` grows from 12 to 38: transitions (whip, cells, slices, iris, glitch, flip3D, flash), text (scramble, popIn, flipIn, roll, odometer, barReveal), camera and energy (shake, punch, orbit, rgbSplit, drawOn) and component transitions (magicMove, whipPan, diveIn, flipLift, squashDrop, snapScroll, unfold, tileSwirl). Each move is its own file under `runtime/moves/`; shared helpers are `paper-motion/kit`; one whip blur filter per stage.
|
|
23
|
+
- `pamo new --move` accepts any transition in the library; `pamo board --motion-blur n` renders option boards with real motion blur.
|
|
24
|
+
- `PAPER_MOTION_SITE` overrides the site URL everywhere the engine writes links. OG images are rendered by the engine.
|
|
25
|
+
- Examples: the Halo explainer and the Orbit keynote. Sound effects keep their gains next to a voice.
|
|
26
|
+
- The engine ships no audio. A user's own recording (`pamo keys`) gives real keystrokes; without one they are synthesized.
|
|
27
|
+
|
|
28
|
+
## 0.1.0 · 2026-11-08
|
|
29
|
+
|
|
30
|
+
First public release of the engine as the unscoped npm package `paper-motion` (previously `@paper-motion/engine`, private).
|
|
31
|
+
|
|
32
|
+
- `pamo` CLI: `setup`, `doctor`, `new`, `still`, `render` (mp4, prores, gif, webm; `--motion-blur`, `--dpr`, `--loudness`, `--audio`), `preview`, `board`, `shaders`, `examples`, `status`, `keys`, `shader-set`.
|
|
33
|
+
- Runtime: scenes as pure functions of time, Paper Shaders by name with their React props, seamless shader loops, `paper-motion/brand` (`loadBrand`, `B.shader`, `B.style`, `B.cue`, `B.sound`), `paper-motion/moves` (12 moves), synthesized sound effects mixed into renders.
|
|
34
|
+
- Dependencies are found wherever npm puts them, so an install does not re-install them inside the package. Four bundled examples: breath, signal, install-loop, chrome-endcard.
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Installed globally (`npm i -g paper-motion`), the same commands run as `paper-mo
|
|
|
31
31
|
|
|
32
32
|
### Resources from paper-motion.app
|
|
33
33
|
|
|
34
|
-
The registry at [paper-motion.app](https://paper-motion.app) holds named moves, backgrounds, palettes, font pairs and recipes. Agents pick them for a brand, propose options as a board link, and install what the user chooses as code the user owns.
|
|
34
|
+
The registry at [paper-motion.app](https://paper-motion.vercel.app) holds named moves, backgrounds, palettes, font pairs and recipes. Agents pick them for a brand, propose options as a board link, and install what the user chooses as code the user owns.
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
npx paper-motion search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff" # find resources by mood, mode, color fit
|
|
@@ -41,7 +41,7 @@ npx paper-motion board --url --title "Backgrounds" --look cobalt,geist \
|
|
|
41
41
|
npx paper-motion list background # every resource of a type
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
`https://paper-motion.app/llms.txt` explains the flow to an agent.
|
|
44
|
+
`https://paper-motion.vercel.app/llms.txt` explains the flow to an agent.
|
|
45
45
|
|
|
46
46
|
## Inside a scene
|
|
47
47
|
|
|
@@ -55,6 +55,10 @@ All 30 [Paper Shaders](https://shaders.paper.design), a library of moves (wipes,
|
|
|
55
55
|
frame, terminal typing), synthesized sound effects or real keystrokes split from your own recording, option boards,
|
|
56
56
|
wordmarks and the brand sheet.
|
|
57
57
|
|
|
58
|
+
## Changes
|
|
59
|
+
|
|
60
|
+
[CHANGELOG.md](./CHANGELOG.md) lists what each npm release changed.
|
|
61
|
+
|
|
58
62
|
## License
|
|
59
63
|
|
|
60
64
|
MIT. Paper Shaders are © Paper Design (Apache-2.0).
|
package/bin/pamo.mjs
CHANGED
|
@@ -579,14 +579,15 @@ async function main() {
|
|
|
579
579
|
|
|
580
580
|
case 'add': {
|
|
581
581
|
const ids = args._.slice(1);
|
|
582
|
-
if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
|
|
582
|
+
if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--palette id|brand] [--force] [--name piece] [--brand "Name"] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --palette installs backgrounds recolored into a palette (a registry palette, or "brand" for brand/tokens.json); --set still wins. --force replaces values already in the project. --brand names the brand in brand/tokens.json; palettes, fonts and recipes also write brand/motion.json and brand/sound.json defaults when missing. --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
|
|
583
583
|
const { add } = await import('../lib/registry.mjs');
|
|
584
584
|
const set = process.argv.flatMap((a, i, all) => (a === '--set' ? [all[i + 1]] : a.startsWith('--set=') ? [a.slice(6)] : [])).filter(Boolean);
|
|
585
|
-
const report = await add(ids.filter((x) => !set.includes(x)), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, set });
|
|
585
|
+
const report = await add(ids.filter((x) => !set.includes(x) && x !== args.palette), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, brand: typeof args.brand === 'string' ? args.brand : undefined, set, palette: typeof args.palette === 'string' ? args.palette : undefined });
|
|
586
586
|
for (const r of report) {
|
|
587
587
|
console.log(`✓ ${r.id} (${r.type})${r.wrote.length ? ` → ${[...new Set(r.wrote)].join(', ')}` : ''}`);
|
|
588
588
|
for (const f of r.skipped) console.log(` kept your ${f} (it differs; --force to replace)`);
|
|
589
589
|
for (const c of r.conflicts) console.log(` kept your value at ${c} (--force to replace)`);
|
|
590
|
+
for (const n of r.notes.filter((x) => x.startsWith('the brand'))) console.log(` ${n}`);
|
|
590
591
|
}
|
|
591
592
|
const last = report.filter((r) => ids.includes(r.id) && r.usage);
|
|
592
593
|
if (last.length === 1) console.log(`\nUsage:\n${last[0].usage.split('\n').map((l) => ` ${l}`).join('\n')}`);
|
|
@@ -637,7 +638,7 @@ async function main() {
|
|
|
637
638
|
} else await withServer(demo, (url) => renderVideo({ url: url(`?render&demo=${it.id}`), out: mp4, from: 0, to: 5, sfx: false, crf: 16, motionBlur: Number(args['motion-blur'] ?? 4) }));
|
|
638
639
|
const vf = 'scale=1280:-2';
|
|
639
640
|
const a = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', mp4, '-vf', vf, '-c:v', 'libvpx-vp9', '-crf', '38', '-b:v', '0', '-row-mt', '1', '-an', path.join(out, `${it.id}.webm`)]);
|
|
640
|
-
const b = spawnSync('ffmpeg', ['-v', 'error', '-y', '-ss', String(POSTER[it.type] ?? 1.5), '-i', mp4, '-frames:v', '1', '-vf', vf, '-q:v', '4', path.join(out, `${it.id}.jpg`)]);
|
|
641
|
+
const b = spawnSync('ffmpeg', ['-v', 'error', '-y', '-ss', String(it.preview?.at ?? POSTER[it.type] ?? 1.5), '-i', mp4, '-frames:v', '1', '-vf', vf, '-q:v', '4', path.join(out, `${it.id}.jpg`)]);
|
|
641
642
|
if (a.status || b.status) throw new Error(`ffmpeg failed for ${it.id}: ${a.stderr}${b.stderr}`);
|
|
642
643
|
}
|
|
643
644
|
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
package/lib/customize.mjs
CHANGED
|
@@ -4,12 +4,71 @@
|
|
|
4
4
|
|
|
5
5
|
export const MOVE_TYPES = ['component', 'transition', 'text', 'camera'];
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A resource's params. A background whose entry paints a CSS `background` behind the shader gets it as a virtual
|
|
9
|
+
* color param, so recoloring, `--set background=#…` and the reply line can carry it like any other value.
|
|
10
|
+
*/
|
|
11
|
+
export const paramsOf = (item) => {
|
|
12
|
+
const ps = item.params ?? [];
|
|
13
|
+
const bg = item.type === 'background' ? item.install?.entry?.background : undefined;
|
|
14
|
+
return typeof bg === 'string' && !ps.some((p) => p.name === 'background') ? [...ps, { name: 'background', type: 'color', default: bg, description: 'the color behind the shader', group: 'main' }] : ps;
|
|
15
|
+
};
|
|
16
|
+
|
|
7
17
|
/** Param defaults by name. */
|
|
8
|
-
export const defaults = (item) => Object.fromEntries((item
|
|
18
|
+
export const defaults = (item) => Object.fromEntries(paramsOf(item).map((p) => [p.name, p.default]));
|
|
9
19
|
|
|
10
20
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
11
21
|
/** Params whose value differs from the default. */
|
|
12
|
-
export const changed = (item, values) => (item
|
|
22
|
+
export const changed = (item, values) => paramsOf(item).filter((p) => p.name in values && !same(values[p.name], p.default));
|
|
23
|
+
|
|
24
|
+
// ── color families: a background in a palette ──────────────────
|
|
25
|
+
|
|
26
|
+
const HEX = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i;
|
|
27
|
+
const srgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
28
|
+
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
29
|
+
const unlin = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
30
|
+
const lum = (h) => { const [r, g, b] = srgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
31
|
+
const hex = (rgbLin) => `#${rgbLin.map((c) => Math.round(Math.min(1, Math.max(0, unlin(c))) * 255).toString(16).padStart(2, '0')).join('')}`;
|
|
32
|
+
/** `color` moved toward black or white until its luminance is `y`: the hue stays, the tone changes. */
|
|
33
|
+
function atLuminance(color, y) {
|
|
34
|
+
const c = srgb(color).map(lin), yc = lum(color);
|
|
35
|
+
if (Math.abs(yc - y) < 1e-4) return color.slice(0, 7);
|
|
36
|
+
if (y > yc) { const t = yc >= 1 ? 0 : (y - yc) / (1 - yc); return hex(c.map((v) => v + t * (1 - v))); }
|
|
37
|
+
const t = yc <= 0 ? 0 : 1 - y / yc;
|
|
38
|
+
return hex(c.map((v) => v * (1 - t)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The hexes of a palette (the values of a palette resource, or `brand/tokens.json`'s colors with hexes). */
|
|
42
|
+
export function paletteColors(pal) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const k of ['ground', 'figure', 'muted', 'surface', 'accent']) { const v = pal?.[k]; const h = typeof v === 'string' ? v : v?.hex; if (HEX.test(h ?? '')) out.push(h); }
|
|
45
|
+
for (const h of pal?.ramp ?? []) if (HEX.test(h)) out.push(h);
|
|
46
|
+
return [...new Set(out.map((h) => h.slice(0, 7).toLowerCase()))];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A background's color values in a palette: every color the resource uses keeps its own tone (a dark background
|
|
51
|
+
* stays dark, a bright highlight stays bright, transparency stays) and takes the hue of the palette color nearest in
|
|
52
|
+
* tone, so each background becomes a family: the same material in any brand's colors. Returns the values to apply.
|
|
53
|
+
*/
|
|
54
|
+
export function recolor(item, pal) {
|
|
55
|
+
const ladder = paletteColors(pal).map((h) => ({ h, y: lum(h) }));
|
|
56
|
+
if (item.type !== 'background' || !ladder.length) return {};
|
|
57
|
+
const one = (v) => {
|
|
58
|
+
const m = typeof v === 'string' && v.match(HEX);
|
|
59
|
+
if (!m) return v;
|
|
60
|
+
if (m[2] && parseInt(m[2], 16) === 0) return v; // fully transparent: nothing to color
|
|
61
|
+
const y = lum(v);
|
|
62
|
+
const near = ladder.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a));
|
|
63
|
+
return atLuminance(near.h, y) + (m[2] ? m[2] : '');
|
|
64
|
+
};
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const p of paramsOf(item)) {
|
|
67
|
+
if (p.type === 'color') out[p.name] = one(p.default);
|
|
68
|
+
else if (p.type === 'colors' && Array.isArray(p.default)) out[p.name] = p.default.map(one);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
13
72
|
|
|
14
73
|
/** A JS literal: strings in single quotes, `ease.*` left as code, arrays and objects inline. */
|
|
15
74
|
export function literal(v) {
|
|
@@ -35,7 +94,7 @@ export function parseSet(item, pairs) {
|
|
|
35
94
|
const i = pair.indexOf('=');
|
|
36
95
|
if (i < 0) throw new Error(`--set needs key=value, got "${pair}"`);
|
|
37
96
|
const k = pair.slice(0, i), raw = pair.slice(i + 1);
|
|
38
|
-
if (!(item
|
|
97
|
+
if (!paramsOf(item).some((p) => p.name === k)) throw new Error(`"${item.id}" has no param "${k}" (params: ${paramsOf(item).map((p) => p.name).join(', ')})`);
|
|
39
98
|
try { out[k] = JSON.parse(raw); } catch { out[k] = raw; }
|
|
40
99
|
}
|
|
41
100
|
return out;
|
|
@@ -57,7 +116,9 @@ function moveFile(item, content, values) {
|
|
|
57
116
|
|
|
58
117
|
function backgroundEntry(item, values) {
|
|
59
118
|
const e = item.install.entry ?? {};
|
|
60
|
-
|
|
119
|
+
const props = { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) };
|
|
120
|
+
const bg = typeof e.background === 'string' && typeof values.background === 'string' ? values.background : e.background;
|
|
121
|
+
return { ...e, props, ...(bg !== undefined ? { background: bg } : {}) };
|
|
61
122
|
}
|
|
62
123
|
|
|
63
124
|
function paletteTokens(item, values) {
|
|
@@ -79,8 +140,8 @@ function fontTokens(item, values) {
|
|
|
79
140
|
function recipeConfig(values) {
|
|
80
141
|
const cfg = {}, groups = {};
|
|
81
142
|
for (const [k, v] of Object.entries(values)) {
|
|
82
|
-
const m = k.match(/^(proof|step)(\d)(Label|Title|Body)$/);
|
|
83
|
-
if (m) { const g =
|
|
143
|
+
const m = k.match(/^(proof|step|shot)(\d)(Label|Title|Body|Image|Caption)$/);
|
|
144
|
+
if (m) { const g = { proof: 'proof', step: 'steps', shot: 'shots' }[m[1]]; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
|
|
84
145
|
if (k === 'format') cfg.format = String(v).split('x').map(Number);
|
|
85
146
|
else cfg[k] = v === '' ? null : v;
|
|
86
147
|
}
|
|
@@ -120,7 +181,7 @@ export function apply(item, values) {
|
|
|
120
181
|
}
|
|
121
182
|
|
|
122
183
|
/** 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' } = {}) {
|
|
184
|
+
export function code(item, values, { showAll = false, site = 'https://paper-motion.vercel.app' } = {}) {
|
|
124
185
|
const v = { ...defaults(item), ...values }, diff = changed(item, v), out = apply(item, v);
|
|
125
186
|
const args = setArgs(item, v);
|
|
126
187
|
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(' ')}` : ''}`;
|
package/lib/live-board.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A board: 2–4 labelled options the agent proposes, carried in a paper-motion.app link so the user can see them
|
|
2
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))>
|
|
3
|
+
// https://paper-motion.vercel.app/b#<base64url(deflate-raw(JSON))>
|
|
4
4
|
// This module runs in Node (`pamo board --url`) and in the browser (the /b page), so it has no Node imports: it
|
|
5
5
|
// picks zlib or CompressionStream at call time. Plain ESM; the site copies it next to customize.mjs.
|
|
6
6
|
|
package/lib/registry.mjs
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { apply as applyValues, parseSet } from './customize.mjs';
|
|
8
|
+
import { apply as applyValues, defaults, paletteColors, parseSet, recolor } from './customize.mjs';
|
|
9
9
|
|
|
10
10
|
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
11
|
const REPO = path.resolve(ENGINE, '..', '..');
|
|
12
|
-
|
|
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(/\/$/, '');
|
|
13
14
|
const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
|
|
14
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' };
|
|
15
16
|
|
|
@@ -121,7 +122,10 @@ export function llmsTxt(items, site, { full = false } = {}) {
|
|
|
121
122
|
4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
|
|
122
123
|
\`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
|
|
123
124
|
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
|
+
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.
|
|
125
129
|
5. **Make the video.** Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
|
|
126
130
|
\`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
|
|
127
131
|
|
|
@@ -133,6 +137,11 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
133
137
|
- \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
|
|
134
138
|
- Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
|
|
135
139
|
- Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
|
|
140
|
+
**Every background is a color family**: a board with a \`look.palette\` shows each background recolored into that
|
|
141
|
+
palette (each of its colors keeps its tone and takes the nearest palette hue, so a dark one stays dark), and the
|
|
142
|
+
reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
|
|
143
|
+
installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
|
|
144
|
+
texture first; the color comes from the palette.
|
|
136
145
|
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
|
|
137
146
|
|
|
138
147
|
\`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
|
|
@@ -208,7 +217,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
|
|
|
208
217
|
if (problems.length) { const e = new Error(`registry: ${problems.length} problem(s)\n ${problems.join('\n ')}`); e.problems = problems; throw e; }
|
|
209
218
|
fs.mkdirSync(path.join(out, 'r'), { recursive: true });
|
|
210
219
|
// 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.
|
|
220
|
+
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; }
|
|
212
221
|
const index = {
|
|
213
222
|
name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
|
|
214
223
|
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)) })),
|
|
@@ -267,8 +276,33 @@ function mergeInto(base, frag, force, at = '', conflicts = []) {
|
|
|
267
276
|
* Installs resources into the project at `cwd`, resolving `requires.items` first. Returns a report per resource.
|
|
268
277
|
* Never overwrites a changed file or a different brand value unless `force`.
|
|
269
278
|
*/
|
|
270
|
-
|
|
279
|
+
// what a recipe reads beside the tokens and the shader set; written once, when the project has none
|
|
280
|
+
const MOTION_DEFAULT = { pace: 'medium', camera: 'still', text: 'rise', color: 'constant', ending: 'wordmark', timing: null, moves: [] };
|
|
281
|
+
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 };
|
|
282
|
+
|
|
283
|
+
export async function add(ids, { cwd = process.cwd(), registry, force = false, name, brand, set = [], palette } = {}) {
|
|
271
284
|
const reg = resolveRegistry(registry), done = new Map(), report = [];
|
|
285
|
+
// --palette <id|brand>: backgrounds install recolored into that palette (the registry's, or brand/tokens.json)
|
|
286
|
+
let pal = null;
|
|
287
|
+
if (palette === 'brand') {
|
|
288
|
+
const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {});
|
|
289
|
+
pal = { ...(tokens.colors ?? {}), ramp: tokens.ramp };
|
|
290
|
+
if (!paletteColors(pal).length) throw new Error('--palette brand: brand/tokens.json has no colors yet (pamo add <palette> first)');
|
|
291
|
+
} else if (palette) {
|
|
292
|
+
const p = await getItem(palette, reg);
|
|
293
|
+
if (p.type !== 'palette') throw new Error(`--palette takes a palette id (or "brand"), not "${palette}" (${p.type})`);
|
|
294
|
+
pal = defaults(p);
|
|
295
|
+
}
|
|
296
|
+
const scaffold = (r) => {
|
|
297
|
+
// a brand folder a recipe can run on: its name, a pace and sound cues (silent pieces are the usual surprise)
|
|
298
|
+
const tokensFile = path.join(cwd, 'brand/tokens.json'), tokens = readJson(tokensFile, {});
|
|
299
|
+
if (brand && (force || !tokens.brand)) { tokens.brand = brand; writeJson(tokensFile, tokens); r.wrote.push('brand/tokens.json brand'); }
|
|
300
|
+
for (const [file, def] of [['brand/motion.json', MOTION_DEFAULT], ['brand/sound.json', SOUND_DEFAULT]]) {
|
|
301
|
+
const f = path.join(cwd, file);
|
|
302
|
+
if (!fs.existsSync(f)) { writeJson(f, def); r.wrote.push(`${file} (defaults)`); }
|
|
303
|
+
}
|
|
304
|
+
if (!tokens.brand && !brand) r.notes.push('the brand has no name yet: pamo add --brand "Name" (recipes show it as the wordmark)');
|
|
305
|
+
};
|
|
272
306
|
const lockFile = path.join(cwd, 'paper-motion.json'), lock = readJson(lockFile, { registry: reg.url ?? 'local', items: {} });
|
|
273
307
|
async function one(id) {
|
|
274
308
|
if (done.has(id)) return;
|
|
@@ -277,7 +311,10 @@ export async function add(ids, { cwd = process.cwd(), registry, force = false, n
|
|
|
277
311
|
for (const dep of it.requires?.items ?? []) await one(dep);
|
|
278
312
|
// --set key=value (or --set id:key=value with several ids): the same output the site's code tabs show
|
|
279
313
|
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
|
|
314
|
+
if (pairs.length || (pal && it.type === 'background')) {
|
|
315
|
+
const parsed = parseSet(it, pairs), explicit = Object.fromEntries(pairs.map((x) => x.slice(0, x.indexOf('='))).map((k) => [k, parsed[k]]));
|
|
316
|
+
it = { ...it, ...applyValues(it, { ...parsed, ...(pal ? recolor(it, pal) : {}), ...explicit }) };
|
|
317
|
+
}
|
|
281
318
|
const r = { id, type: it.type, wrote: [], skipped: [], conflicts: [], usage: it.usage, notes: it.notes ?? [] };
|
|
282
319
|
const kind = it.install?.kind ?? 'code';
|
|
283
320
|
const base = kind === 'scene' ? path.join('pieces', name ?? it.id) : '';
|
|
@@ -309,6 +346,7 @@ export async function add(ids, { cwd = process.cwd(), registry, force = false, n
|
|
|
309
346
|
r.conflicts.push(...mergeInto(tokens, it.install.entry ?? {}, force).map((k) => `brand/tokens.json ${k}`));
|
|
310
347
|
writeJson(f, tokens); r.wrote.push('brand/tokens.json');
|
|
311
348
|
}
|
|
349
|
+
if (kind === 'tokens' || kind === 'scene') scaffold(r);
|
|
312
350
|
lock.items[it.id] = it.version;
|
|
313
351
|
report.push(r);
|
|
314
352
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paper-motion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Frame-exact video renderer for HTML scenes built with Paper Shaders: the engine behind the paper-motion agent skills.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"video",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"templates/",
|
|
36
36
|
"examples/",
|
|
37
37
|
"!examples/**/renders/",
|
|
38
|
-
"README.md"
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
39
40
|
],
|
|
40
41
|
"engines": {
|
|
41
42
|
"node": ">=18.17"
|
package/runtime/brand.js
CHANGED
|
@@ -53,6 +53,8 @@ export async function loadBrand(root = '/brand') {
|
|
|
53
53
|
if (!e?.shader) throw new Error(`paper-motion/brand: no shader entry "${id}" in brand/shader-set/set.json`);
|
|
54
54
|
const props = { ...e.props };
|
|
55
55
|
if (e.image) props.image = e.image === 'wordmark' ? B.wordmark.onDark ?? B.wordmark.onLight : e.image === 'wordmark-dark' ? B.wordmark.onLight ?? B.wordmark.onDark : `${root}/${e.image}`;
|
|
56
|
+
// the color behind the shader (entries with a transparent colorBack rely on it)
|
|
57
|
+
if (e.background) { const node = typeof el === 'string' ? document.querySelector(el) : el; if (node && !node.style.background) node.style.background = e.background; }
|
|
56
58
|
const layer = s.shader(el, e.shader, props, { ...(e.loop ? { loop: e.loop } : {}), ...options });
|
|
57
59
|
// the entry's periodic motion, as in its rendered loop (static shaders stay alive)
|
|
58
60
|
if (e.motion) { const D = set.format?.duration ?? s.duration, { param, from, to, cycles = 1 } = e.motion; s.update((t) => layer.set({ [param]: from + (to - from) * (0.5 - 0.5 * Math.cos((2 * Math.PI * cycles * t) / D)) })); }
|