paper-motion 0.2.5 → 0.2.6

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 CHANGED
@@ -2,8 +2,16 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
+ ## 0.2.6 · 2026-11-10
6
+
7
+ - `camera()` in `paper-motion/kit`: a 3D world for a piece (`place`, `at`, `travel` with keyframes, `focus` with depth of field and gradual exits). Every recipe is rebuilt on it: a corridor the camera travels (launch, manifesto), a column it descends (three-things), a line it dollies along with a pull-back recap (explainer), a window it tilts into and punches (product-demo), a slab it swings around (before-after), a sleeve it orbits (release).
8
+ - A new resource type, **shots**: 3–6 s camera gestures (`focus-pull`, `pass-over`, `cursor-guide`, `push-in`, `turn-in`), installed like recipes, chained before one or used alone.
9
+ - `scramble` measures letters by layout width, so it works inside a 3D world.
10
+
5
11
  ## 0.2.5 · unreleased
6
12
 
13
+ - Recipes declare `beats` (the structure as a timeline); boards draw them, and a recipe board may hold a structure of the agent's own (`--option "@Title | 18 | Hook 0-2 …"`), which `parseReply` returns as `structure`.
14
+ - Three recipes: `manifesto` (words only, three statements and a turn), `three-things` (three cards, three mechanics) and `before-after` (two states, one wipe).
7
15
  - Font pairs keep their own stylesheet when the family is unchanged, so self-hosted faces work: the new `geist-serif` pair (Geist Serif headlines served by paper-motion.app, Geist text, Geist Mono labels) is the site's own look.
8
16
 
9
17
  ## 0.2.4 · 2026-11-09
package/bin/pamo.mjs CHANGED
@@ -647,18 +647,22 @@ async function main() {
647
647
  const only = typeof args.only === 'string' ? args.only.split(',') : null;
648
648
  const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
649
649
  fs.mkdirSync(out, { recursive: true });
650
- const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe'].includes(i.type) && (!only || only.includes(i.id)));
651
- const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5 };
650
+ const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe', 'shot'].includes(i.type) && (!only || only.includes(i.id)));
651
+ const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5, shot: 1.8 };
652
652
  const demo = path.join(reg.dir, '_demo', 'index.html');
653
653
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
654
654
  try {
655
655
  for (const [k, it] of items.entries()) {
656
656
  process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
657
657
  const mp4 = path.join(tmp, `${it.id}.mp4`);
658
- if (it.type === 'recipe') {
658
+ if (it.type === 'recipe' || it.type === 'shot') {
659
659
  // a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
660
660
  const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
661
- fs.copyFileSync(path.join(reg.dir, 'recipe', it.id, 'scene.html'), copy);
661
+ // the demo brand's copy goes in through the same generator `pamo add --set` uses (preview.values)
662
+ const { apply: applyValues } = await import('../lib/customize.mjs');
663
+ const sceneFile = { path: 'scene.html', content: fs.readFileSync(path.join(reg.dir, it.type, it.id, 'scene.html'), 'utf8') };
664
+ const out = applyValues({ ...it, files: [sceneFile] }, it.preview?.values ?? {});
665
+ fs.writeFileSync(copy, out.files[0].content);
662
666
  process.chdir(path.join(reg.dir, '_demo'));
663
667
  try { await withServer(copy, (url) => renderVideo({ url: url('?render'), out: mp4, sfx: false, crf: 18, motionBlur: Number(args['motion-blur'] ?? 3) })); }
664
668
  finally { process.chdir(here); fs.rmSync(copy, { force: true }); }
package/lib/customize.mjs CHANGED
@@ -141,8 +141,12 @@ function fontTokens(item, values) {
141
141
  function recipeConfig(values) {
142
142
  const cfg = {}, groups = {};
143
143
  for (const [k, v] of Object.entries(values)) {
144
- const m = k.match(/^(proof|step|shot)(\d)(Label|Title|Body|Image|Caption)$/);
145
- 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; }
144
+ const m = k.match(/^(proof|step|shot|thing)(\d)(Label|Title|Body|Image|Caption)$/);
145
+ if (m) { const g = { proof: 'proof', step: 'steps', shot: 'shots', thing: 'things' }[m[1]]; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
146
+ const ba = k.match(/^(before|after)(Tag|Line|Image)$/);
147
+ if (ba) { (cfg[ba[1]] ??= {})[ba[2].toLowerCase()] = v; continue; }
148
+ const st = k.match(/^statement(\d)$/);
149
+ if (st) { (cfg.statements ??= [])[st[1] - 1] = v; continue; }
146
150
  if (k === 'format') cfg.format = String(v).split('x').map(Number);
147
151
  else cfg[k] = v === '' ? null : v;
148
152
  }
@@ -154,10 +158,17 @@ function recipeScene(content, values) {
154
158
  const start = content.indexOf('const CONFIG = {');
155
159
  if (start < 0) return content;
156
160
  const end = content.indexOf('\n};', start) + 3;
157
- const body = Object.entries(cfg).map(([k, v]) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
161
+ // the original entries, by key (an entry runs until the next top-level key), so settings that are not params
162
+ // (backgroundOpacity, turnWord, a card's label…) survive a --set
163
+ const original = content.slice(start + 'const CONFIG = {\n'.length, end - 3).split(/\n(?= \w+: )/);
164
+ const kept = new Map(original.map((e) => [e.match(/^ (\w+):/)?.[1], e]).filter(([k]) => k));
165
+ const render = (k, v) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
158
166
  ? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
159
- : ` ${k}: ${v === null ? 'null' : literal(v)},`)).join('\n');
160
- return `${content.slice(0, start)}const CONFIG = {\n${body}\n};${content.slice(end)}`;
167
+ : ` ${k}: ${v === null ? 'null' : literal(v)},`);
168
+ const lines = [];
169
+ for (const [k, e] of kept) lines.push(k in cfg ? render(k, cfg[k]) : e);
170
+ for (const [k, v] of Object.entries(cfg)) if (!kept.has(k)) lines.push(render(k, v));
171
+ return `${content.slice(0, start)}const CONFIG = {\n${lines.join('\n')}\n};${content.slice(end)}`;
161
172
  }
162
173
 
163
174
  // ── the public API ──────────────────────────────────────────────
@@ -171,7 +182,7 @@ export function apply(item, values) {
171
182
  const files = (item.files ?? []).map((f) => {
172
183
  if (f.content === undefined) return f;
173
184
  if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
174
- if (item.type === 'recipe' && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
185
+ if ((item.type === 'recipe' || item.type === 'shot') && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
175
186
  return f;
176
187
  });
177
188
  let entry = item.install?.entry;
@@ -216,7 +227,7 @@ export function code(item, values, { showAll = false, site = 'https://paper-moti
216
227
  { key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
217
228
  { 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}` },
218
229
  ];
219
- } else if (item.type === 'recipe') {
230
+ } else if (item.type === 'recipe' || item.type === 'shot') {
220
231
  const scene = out.files.find((f) => /scene\.html$/.test(f.path));
221
232
  tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
222
233
  }
package/lib/live-board.js CHANGED
@@ -9,7 +9,7 @@
9
9
  export const BOARD_VERSION = 1;
10
10
  export const MAX_PAYLOAD = 8 * 1024;
11
11
  const LABELS = ['A', 'B', 'C', 'D'];
12
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
12
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
13
13
 
14
14
  /**
15
15
  * @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
package/lib/registry.mjs CHANGED
@@ -12,8 +12,8 @@ const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
12
  const REPO = path.resolve(ENGINE, '..', '..');
13
13
  // until the paper-motion.app domain is connected, the live site is the vercel URL
14
14
  export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
15
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
16
- 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
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
16
+ 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', shot: 'Shots' };
17
17
 
18
18
  /** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
19
19
  export function resolveRegistry(given) {
@@ -119,7 +119,9 @@ export function llmsTxt(items, site, { full = false } = {}) {
119
119
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
120
120
  Mirror it back before proposing anything. **You never choose a look for the user.** These decisions always go
121
121
  through a board, in this order, one board each: palette · font pair · background(s) · moves by role (how things
122
- enter, how one thing becomes the next, how text arrives; let them pick several with \`choose\`) · recipe. Skip a
122
+ enter, how one thing becomes the next, how text arrives; let them pick several with \`choose\`) · recipe or shots
123
+ (shots are 3–6 s camera gestures: a close-up, a pass over a component, a cursor that clicks; they chain before a
124
+ recipe or stand alone). Skip a
123
125
  board only when the brand folder already settles that decision, and say so.
124
126
  3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
125
127
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
@@ -155,10 +157,13 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
155
157
  reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
156
158
  installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
157
159
  texture first; the color comes from the palette.
158
- - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\` and \`beats\` (the structure as a
159
- timeline: name, from, to, what). A recipe board shows the beats, so the user chooses a rhythm, not a name; you may add
160
- one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`, format below), which you
161
- then build from scratch.
160
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes and shots: \`duration\`, \`formats\` and \`beats\` (the structure as a
161
+ timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
162
+ name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
163
+ format below), which you then build from scratch. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
164
+ order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
165
+ reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
166
+ the reference for its block.
162
167
 
163
168
  \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
164
169
  sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",
package/runtime/brand.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // const s = scene({ ..., background: B.color('ground') });
6
6
  // B.style(title, 'display'); B.shader(s, '#bg', 'mesh-ground'); B.cue(s, 'transition', 2.4); B.sound(s);
7
7
  import { PACE } from './moves.js';
8
+ import { mixColor } from './core.js';
8
9
 
9
10
  const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text()) : null)).catch(() => null);
10
11
  const sections = (md) => Object.fromEntries((md ?? '').split(/^## /m).slice(1).map((b) => { const i = b.indexOf('\n'); return [b.slice(0, i).trim(), b.slice(i + 1).trim()]; }));
@@ -33,6 +34,15 @@ export async function loadBrand(root = '/brand') {
33
34
  name: wm.text ?? tokens?.brand ?? 'brand',
34
35
  /** A color by role (ground, figure, muted, surface, accent…), with a fallback. */
35
36
  color: (role, fallback = '#888888') => C[role]?.hex ?? fallback,
37
+ /**
38
+ * The surface for cards and panels, lifted toward the figure when the brand's own surface is too close to the
39
+ * ground to read (a black brand with a near-black surface would draw black cards on black).
40
+ */
41
+ surface: (lift = 0.14) => {
42
+ const g = C.ground?.hex ?? '#000000', s = C.surface?.hex ?? g, f = C.figure?.hex ?? '#ffffff';
43
+ const lum = (h) => { const n = parseInt(h.slice(1, 7), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255; };
44
+ return Math.abs(lum(s) - lum(g)) < 0.06 ? mixColor(g, f, lift) : s;
45
+ },
36
46
  ramp: tokens?.ramp ?? [C.ground?.hex, C.surface?.hex, C.accent?.hex].filter(Boolean),
37
47
  /** CSS font-family for a font key (display, text, mono). */
38
48
  font: fam,
package/runtime/kit.js CHANGED
@@ -96,3 +96,76 @@ export function copy(el) {
96
96
  }
97
97
 
98
98
  export const partsOf = (el) => new Map([...el.querySelectorAll('[data-part]')].map((e) => [e.dataset.part, e]));
99
+
100
+ // ── a 3D camera over a world ────────────────────────────────────
101
+ // A recipe is a world the camera moves through, not a deck of cards. `stage` gets the perspective; `world` holds
102
+ // children placed in space with `place()`; the camera's position is applied to the world as its inverse.
103
+ // const cam = camera(stage, world, { perspective: 2000 });
104
+ // cam.place(title, { z: -1200, yaw: 8 });
105
+ // s.update((t) => { cam.travel(t, [[0, { z: 1400 }], [3, { z: -200, yaw: 6 }, ease.inOutCubic]]); cam.focus(0, 900); });
106
+ // Rules: never set opacity < 1 on `world` (it flattens its children); fade the placed elements instead.
107
+ const CAM = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0, roll: 0, zoom: 1 };
108
+ export function camera(stage, world, { perspective = 2000, origin = '50% 50%' } = {}) {
109
+ Object.assign(stage.style, { perspective: `${perspective}px`, perspectiveOrigin: origin });
110
+ Object.assign(world.style, { position: 'absolute', inset: 0, transformStyle: 'preserve-3d', transformOrigin: '50% 50%' });
111
+ const placed = [];
112
+ let pos = { ...CAM };
113
+ const P = perspective;
114
+ const cam = {
115
+ /** Where the camera is (the last `at`/`travel`). */
116
+ get pos() { return pos; },
117
+ /** Puts an element in the world, centred on its own box: { x, y, z, yaw, pitch, roll, scale }. */
118
+ place(el, p = {}) {
119
+ const q = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0, roll: 0, scale: 1, ...p };
120
+ Object.assign(el.style, { position: 'absolute', left: '50%', top: '50%', transformStyle: 'preserve-3d', backfaceVisibility: 'hidden' });
121
+ el.style.transform = `translate(-50%, -50%) translate3d(${q.x}px, ${q.y}px, ${q.z}px) rotateY(${q.yaw}deg) rotateX(${q.pitch}deg) rotateZ(${q.roll}deg) scale(${q.scale})`;
122
+ el._pmPlace = q;
123
+ if (!placed.includes(el)) placed.push(el);
124
+ return el;
125
+ },
126
+ /** Moves the camera: the world gets the inverse transform. Fields missing keep the current value. */
127
+ at(p) {
128
+ pos = { ...pos, ...p };
129
+ world.style.transform = `scale(${pos.zoom}) rotateZ(${-pos.roll}deg) rotateX(${-pos.pitch}deg) rotateY(${-pos.yaw}deg) translate3d(${-pos.x}px, ${-pos.y}px, ${-pos.z}px)`;
130
+ return pos;
131
+ },
132
+ /** Keyframes for the camera: [[t, { x, y, z, yaw, pitch, roll, zoom }, ease?], …]. Fields carry over between frames. */
133
+ travel(t, frames) {
134
+ const full = [];
135
+ let acc = { ...CAM, ...frames[0][1] };
136
+ for (const [time, p, e] of frames) { acc = { ...acc, ...p }; full.push([time, acc, e]); }
137
+ if (t <= full[0][0]) return cam.at(full[0][1]);
138
+ for (let i = 1; i < full.length; i++) {
139
+ const [t1, v1, e = ease.inOutCubic] = full[i], [t0, v0] = full[i - 1];
140
+ if (t <= t1) { const q = e(clamp((t - t0) / (t1 - t0))); return cam.at(Object.fromEntries(Object.keys(CAM).map((k) => [k, lerp(v0[k], v1[k], q)]))); }
141
+ }
142
+ return cam.at(full[full.length - 1][1]);
143
+ },
144
+ /**
145
+ * Depth of field and presence. Blur follows DEPTH: distance from the focal plane `z` ahead of the camera (sharp
146
+ * within a third of `range`, up to `max` px). Presence follows DISTANCE in 3D from the point the camera looks at:
147
+ * a place is whole within `fade`·0.4 and gone by `fade` (default `range`·0.85), so the next place is never
148
+ * peeking into frame while the camera rests, and while it travels the two overlap instead of leaving an empty
149
+ * frame. A place passing the lens softens and fades over the last stretch: it finishes its exit, never cuts.
150
+ * `fade: Infinity` keeps everything present (a focus pull where only the blur should change).
151
+ */
152
+ focus(z = 0, range = 800, max = 12, { fade = range * 0.85 } = {}) {
153
+ const fx = pos.x, fy = pos.y, fz = pos.z - z; // the point in focus
154
+ for (const el of placed) {
155
+ const q = el._pmPlace;
156
+ const blur = clamp((Math.abs(q.z - fz) - range * 0.33) / range) * max;
157
+ const dist = Math.hypot(q.x - fx, q.y - fy, q.z - fz);
158
+ const present = fade === Infinity ? 1 : 1 - clamp((dist - fade * 0.4) / Math.max(1, fade * 0.6));
159
+ const toLens = pos.z - q.z; // distance left before it passes the camera
160
+ const near = clamp((toLens - P * 0.03) / (P * 0.1)); // the last stretch before the lens, whatever the focus range
161
+ const b = Math.max(blur, (1 - near) * max, (1 - present) * max * 0.6);
162
+ el.style.filter = b > 0.3 ? `blur(${b.toFixed(1)}px)` : 'none';
163
+ const o = Math.min(near, present);
164
+ el.style.opacity = String(o);
165
+ el.style.visibility = o < 0.005 ? 'hidden' : '';
166
+ }
167
+ },
168
+ };
169
+ cam.at({});
170
+ return cam;
171
+ }
@@ -6,7 +6,8 @@ export function scramble(el, t, { start = 0, duration = 1.4, fps = 30, rate = 2,
6
6
  const cs = chars(el);
7
7
  if (!el._labText) {
8
8
  el._labText = cs.map((c) => c.textContent);
9
- cs.forEach((c) => { c.style.width = `${c.getBoundingClientRect().width}px`; c.style.textAlign = 'center'; });
9
+ // layout width, not the on-screen rect: inside a 3D world the rect is scaled by perspective
10
+ cs.forEach((c) => { c.style.width = `${c.offsetWidth}px`; c.style.textAlign = 'center'; });
10
11
  const r = rng(seed); el._labJit = cs.map(() => r.next());
11
12
  }
12
13
  const n = cs.length, tick = Math.floor((t * fps) / rate);
@@ -5,6 +5,8 @@ import { words } from '../kit.js';
5
5
  export function wordRise(el, t, { start = 0, each = 0.08, duration = 0.5, curve = ease.outExpo } = {}) {
6
6
  words(el).forEach((w, i) => {
7
7
  const q = curve(progress(t, start + i * each, start + i * each + duration));
8
- w.style.transform = `translateY(${(1 - q) * 110}%)`;
8
+ // fully below the mask line, and hidden until it moves: tall glyphs overflow a tight line-height box
9
+ w.style.transform = `translateY(${(1 - q) * 125}%)`;
10
+ w.style.opacity = q > 0.001 ? 1 : 0;
9
11
  });
10
12
  }