paper-motion 0.2.4 → 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 +18 -1
- package/bin/pamo.mjs +21 -9
- package/lib/customize.mjs +20 -8
- package/lib/live-board.js +40 -15
- package/lib/registry.mjs +40 -14
- package/package.json +1 -1
- package/runtime/brand.js +10 -0
- package/runtime/kit.js +73 -0
- package/runtime/moves/scramble.js +2 -1
- package/runtime/moves/word-rise.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
-
## 0.2.
|
|
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
|
+
|
|
11
|
+
## 0.2.5 · unreleased
|
|
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).
|
|
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.
|
|
16
|
+
|
|
17
|
+
## 0.2.4 · 2026-11-09
|
|
18
|
+
|
|
19
|
+
- `pamo board --url` waits for the user's choice by default (`--no-wait`, `--wait <minutes>`); `--choose 3` or `--choose 1-3` lets them pick several options, and the reply line lists them (`A+C · type-on · scramble`). `parseReply` returns `picks`.
|
|
20
|
+
- llms.txt: read the installed skill at once, the decisions that always go through a board, every chosen move appears in the piece.
|
|
21
|
+
|
|
22
|
+
## 0.2.3 · 2026-11-09
|
|
6
23
|
|
|
7
24
|
- Boards get short links and a return channel: `pamo board --url` stores the board on the site (`/b/<id>`, 90 days) and falls back to the long link; `--wait [minutes]` prints the reply line when the user chooses in the browser; `--long`, `--forget <id>`. `lib/live-board.js` gains `shortenBoard`, `awaitChoice`, `forgetBoard`; the search filters move to `lib/find.mjs` (shared with the site's MCP at `/mcp`).
|
|
8
25
|
|
package/bin/pamo.mjs
CHANGED
|
@@ -327,9 +327,15 @@ async function main() {
|
|
|
327
327
|
const { parseSet, setArgs } = await import('../lib/customize.mjs');
|
|
328
328
|
const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
|
|
329
329
|
const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
|
|
330
|
-
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--wait
|
|
330
|
+
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action".\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
331
331
|
const LABELS = ['A', 'B', 'C', 'D'];
|
|
332
332
|
const parseOne = async (spec) => {
|
|
333
|
+
if (spec.startsWith('@')) {
|
|
334
|
+
// a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
|
|
335
|
+
const [title, dur, beatsSpec] = spec.slice(1).split('|').map((x) => x.trim());
|
|
336
|
+
const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to what"`); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: m[4] }; });
|
|
337
|
+
return { structure: { title, duration: Number(dur), beats } };
|
|
338
|
+
}
|
|
333
339
|
const [id, kv] = spec.split(':'), it = index[id];
|
|
334
340
|
if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
|
|
335
341
|
if (!kv) return { id, values: {} };
|
|
@@ -346,20 +352,22 @@ async function main() {
|
|
|
346
352
|
}
|
|
347
353
|
const look = {};
|
|
348
354
|
if (typeof args.look === 'string') for (const spec of args.look.split(',')) { const o = await parseOne(spec.trim()); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair, not "${o.id}"`); }
|
|
349
|
-
const
|
|
355
|
+
const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
|
|
356
|
+
const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), ...(choose !== undefined ? { choose } : {}), options };
|
|
350
357
|
const site = typeof args.site === 'string' ? args.site : SITE;
|
|
351
358
|
let frag, short = null;
|
|
352
359
|
try { frag = await encodeBoard(board, index); } catch (e) { console.error(e.message); process.exit(1); }
|
|
353
360
|
// the short link when the site stores it; the long one (the board in the fragment) when it cannot
|
|
354
361
|
if (!args.long) { try { short = await shortenBoard(site, board); } catch (e) { console.error(`(${e.message}; sharing the long link)`); } }
|
|
355
362
|
console.log(short ? short.url : boardUrl(site, frag));
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
const wait = !args['no-wait']; // waiting is the default: the choice comes back without the user pasting anything
|
|
364
|
+
if (short && wait) {
|
|
365
|
+
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
|
|
358
366
|
console.error(`waiting up to ${minutes} min for the choice on ${short.id} (the user can still paste the line)…`);
|
|
359
367
|
const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 });
|
|
360
368
|
if (!choice) { console.error('no choice yet; ask the user to paste the line from the board'); process.exit(3); }
|
|
361
369
|
console.log(choice.line);
|
|
362
|
-
} else if (!short &&
|
|
370
|
+
} else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
|
|
363
371
|
break;
|
|
364
372
|
}
|
|
365
373
|
if (args.forget) {
|
|
@@ -639,18 +647,22 @@ async function main() {
|
|
|
639
647
|
const only = typeof args.only === 'string' ? args.only.split(',') : null;
|
|
640
648
|
const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
|
|
641
649
|
fs.mkdirSync(out, { recursive: true });
|
|
642
|
-
const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe'].includes(i.type) && (!only || only.includes(i.id)));
|
|
643
|
-
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 };
|
|
644
652
|
const demo = path.join(reg.dir, '_demo', 'index.html');
|
|
645
653
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
|
|
646
654
|
try {
|
|
647
655
|
for (const [k, it] of items.entries()) {
|
|
648
656
|
process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
|
|
649
657
|
const mp4 = path.join(tmp, `${it.id}.mp4`);
|
|
650
|
-
if (it.type === 'recipe') {
|
|
658
|
+
if (it.type === 'recipe' || it.type === 'shot') {
|
|
651
659
|
// a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
|
|
652
660
|
const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
|
|
653
|
-
|
|
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);
|
|
654
666
|
process.chdir(path.join(reg.dir, '_demo'));
|
|
655
667
|
try { await withServer(copy, (url) => renderVideo({ url: url('?render'), out: mp4, sfx: false, crf: 18, motionBlur: Number(args['motion-blur'] ?? 3) })); }
|
|
656
668
|
finally { process.chdir(here); fs.rmSync(copy, { force: true }); }
|
package/lib/customize.mjs
CHANGED
|
@@ -130,7 +130,8 @@ function paletteTokens(item, values) {
|
|
|
130
130
|
function fontTokens(item, values) {
|
|
131
131
|
const e = item.install.entry ?? {};
|
|
132
132
|
const f = e.fonts ?? {}, w = values.displayWeight ?? e.type?.display?.weight ?? 400, tr = `${values.tracking ?? parseFloat(e.type?.display?.tracking ?? '0')}em`;
|
|
133
|
-
|
|
133
|
+
// a pair's own css (self-hosted families such as Geist Serif) stays while its family is unchanged; a new family loads from Google Fonts
|
|
134
|
+
const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; const own = f[role]?.css && family === f[role]?.family && !/fonts\.googleapis\.com/.test(f[role].css); return { ...f[role], family, weights, css: own ? f[role].css : gf(family, weights) }; };
|
|
134
135
|
const fonts = { display: pick('display', [w]), text: pick('text', f.text?.weights ?? [400, 500]), mono: pick('mono', f.mono?.weights ?? [400]) };
|
|
135
136
|
const type = { ...e.type, display: { ...e.type?.display, weight: w, tracking: tr }, title: { ...e.type?.title, weight: w, tracking: tr } };
|
|
136
137
|
return { fonts, type };
|
|
@@ -140,8 +141,12 @@ function fontTokens(item, values) {
|
|
|
140
141
|
function recipeConfig(values) {
|
|
141
142
|
const cfg = {}, groups = {};
|
|
142
143
|
for (const [k, v] of Object.entries(values)) {
|
|
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; }
|
|
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; }
|
|
145
150
|
if (k === 'format') cfg.format = String(v).split('x').map(Number);
|
|
146
151
|
else cfg[k] = v === '' ? null : v;
|
|
147
152
|
}
|
|
@@ -153,10 +158,17 @@ function recipeScene(content, values) {
|
|
|
153
158
|
const start = content.indexOf('const CONFIG = {');
|
|
154
159
|
if (start < 0) return content;
|
|
155
160
|
const end = content.indexOf('\n};', start) + 3;
|
|
156
|
-
|
|
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'
|
|
157
166
|
? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
|
|
158
|
-
: ` ${k}: ${v === null ? 'null' : literal(v)},`)
|
|
159
|
-
|
|
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)}`;
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
// ── the public API ──────────────────────────────────────────────
|
|
@@ -170,7 +182,7 @@ export function apply(item, values) {
|
|
|
170
182
|
const files = (item.files ?? []).map((f) => {
|
|
171
183
|
if (f.content === undefined) return f;
|
|
172
184
|
if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
|
|
173
|
-
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) };
|
|
174
186
|
return f;
|
|
175
187
|
});
|
|
176
188
|
let entry = item.install?.entry;
|
|
@@ -215,7 +227,7 @@ export function code(item, values, { showAll = false, site = 'https://paper-moti
|
|
|
215
227
|
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
|
|
216
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}` },
|
|
217
229
|
];
|
|
218
|
-
} else if (item.type === 'recipe') {
|
|
230
|
+
} else if (item.type === 'recipe' || item.type === 'shot') {
|
|
219
231
|
const scene = out.files.find((f) => /scene\.html$/.test(f.path));
|
|
220
232
|
tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
|
|
221
233
|
}
|
package/lib/live-board.js
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
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,
|
|
16
16
|
* look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
|
|
17
|
-
*
|
|
17
|
+
* choose?: number | [number, number], // how many options the user may pick: 1 (default), 3, or [min, max]
|
|
18
|
+
* options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string,
|
|
19
|
+
* structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string }> } }> }} Board
|
|
20
|
+
* An option with `structure` (recipe boards only) is a structure the agent proposes for this brief instead of a recipe.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
/** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
|
|
@@ -25,6 +28,7 @@ export function validateBoard(b, index) {
|
|
|
25
28
|
if (!['resource', 'kit'].includes(b.kind)) errs.push('kind must be "resource" or "kit"');
|
|
26
29
|
if (!b.title || typeof b.title !== 'string') errs.push('title is required');
|
|
27
30
|
if (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4) errs.push('2 to 4 options');
|
|
31
|
+
if (b.choose !== undefined) { const [lo, hi] = chooseRange(b); if (!(Number.isInteger(lo) && Number.isInteger(hi) && lo >= 1 && hi >= lo && hi <= 4)) errs.push('choose must be 1–4 or [min, max] within 1–4'); }
|
|
28
32
|
const seen = new Set();
|
|
29
33
|
for (const [i, o] of (b.options ?? []).entries()) {
|
|
30
34
|
const at = `options[${i}]`;
|
|
@@ -32,7 +36,14 @@ export function validateBoard(b, index) {
|
|
|
32
36
|
if (seen.has(o.label)) errs.push(`${at}.label "${o.label}" repeats`);
|
|
33
37
|
seen.add(o.label);
|
|
34
38
|
if (b.kind === 'resource') {
|
|
35
|
-
if (
|
|
39
|
+
if (o.structure) {
|
|
40
|
+
const st = o.structure;
|
|
41
|
+
if (o.id) errs.push(`${at}: either id or structure, not both`);
|
|
42
|
+
if (!st.title || typeof st.title !== 'string') errs.push(`${at}.structure.title is required`);
|
|
43
|
+
if (!(Number(st.duration) > 0)) errs.push(`${at}.structure.duration (seconds) is required`);
|
|
44
|
+
if (!Array.isArray(st.beats) || st.beats.length < 2 || st.beats.length > 8) errs.push(`${at}.structure.beats: 2 to 8 beats`);
|
|
45
|
+
for (const [j, bt] of (st.beats ?? []).entries()) if (!bt?.name || typeof bt.from !== 'number' || typeof bt.to !== 'number' || bt.to <= bt.from || !bt.what) errs.push(`${at}.structure.beats[${j}] needs name, from < to (seconds) and what`);
|
|
46
|
+
} else if (!o.id) errs.push(`${at}.id is required`);
|
|
36
47
|
else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
|
|
37
48
|
} else {
|
|
38
49
|
if (!Array.isArray(o.items) || !o.items.length) errs.push(`${at}.items is required for a kit board`);
|
|
@@ -41,13 +52,16 @@ export function validateBoard(b, index) {
|
|
|
41
52
|
if (o.values && typeof o.values !== 'object') errs.push(`${at}.values must be an object`);
|
|
42
53
|
}
|
|
43
54
|
if (b.kind === 'resource' && index) {
|
|
44
|
-
const types = new Set(b.options.map((o) => index[o.id]?.type).filter(Boolean));
|
|
55
|
+
const types = new Set(b.options.map((o) => (o.structure ? 'recipe' : index[o.id]?.type)).filter(Boolean));
|
|
45
56
|
if (types.size > 1) errs.push(`a resource board shows one type at a time (got ${[...types].join(', ')})`);
|
|
46
57
|
}
|
|
47
58
|
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
59
|
return errs;
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
/** How many options a board lets the user pick: [min, max]. Defaults to [1, 1]. */
|
|
63
|
+
export const chooseRange = (b) => (Array.isArray(b?.choose) ? [Number(b.choose[0]), Number(b.choose[1])] : [1, Math.max(1, Number(b?.choose ?? 1))]);
|
|
64
|
+
|
|
51
65
|
// ── compression, both runtimes ──────────────────────────────────
|
|
52
66
|
|
|
53
67
|
const b64url = {
|
|
@@ -134,14 +148,18 @@ export async function forgetBoard(site, id) {
|
|
|
134
148
|
// paper-motion: kit B · cobalt geist mesh-graphite whip-pan a kit board choice
|
|
135
149
|
// `reply` (the agent's name for the decision) is appended as "(bg-1)" when present.
|
|
136
150
|
|
|
151
|
+
// paper-motion: A+C · type-on · scramble --set speed=2 several picks on a board with choose > 1
|
|
152
|
+
// paper-motion: D · structure "Three cuts" a structure the agent proposed, chosen
|
|
153
|
+
// `choice` is { label, setArgs? } for one pick, { picks: [{ label, setArgs? }, …] } for several, or { none, text }.
|
|
154
|
+
|
|
137
155
|
export function replyLine(board, choice) {
|
|
138
156
|
const tag = board.reply ? ` (${board.reply})` : '';
|
|
139
157
|
if (choice.none) return `paper-motion: none of ${board.reply ?? `"${board.title}"`}${choice.text ? `, ${choice.text}` : ''}`;
|
|
140
|
-
const
|
|
141
|
-
if (!o) throw new Error(`no option ${
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return `paper-motion: ${
|
|
158
|
+
const picks = choice.picks ?? [choice];
|
|
159
|
+
const found = picks.map((c) => { const o = board.options.find((x) => x.label === c.label); if (!o) throw new Error(`no option ${c.label}`); return { o, sets: (c.setArgs ?? []).join(' ') }; });
|
|
160
|
+
const labels = found.map((f) => f.o.label).join('+');
|
|
161
|
+
if (board.kind === 'kit') return `paper-motion: kit ${labels} · ${found.map((f) => f.o.items.map((i) => i.id).join(' ')).join(' + ')}${tag}`;
|
|
162
|
+
return `paper-motion: ${labels} · ${found.map((f) => (f.o.structure ? `structure "${f.o.structure.title.replace(/"/g, "'")}"` : `${f.o.id}${f.sets ? ` ${f.sets}` : ''}`)).join(' · ')}${tag}`;
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
/** Parses a reply line the user pasted. Returns null when the text is not one. */
|
|
@@ -150,10 +168,17 @@ export function parseReply(text) {
|
|
|
150
168
|
if (!m) return null;
|
|
151
169
|
const body = m[1].trim(), tag = body.match(/\(([^()]+)\)\s*$/)?.[1] ?? null, core = body.replace(/\s*\([^()]+\)\s*$/, '');
|
|
152
170
|
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() }; }
|
|
153
|
-
const kit = core.match(/^kit ([A-D]) · (.+)$/);
|
|
154
|
-
if (kit) return { kind: 'kit', label:
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
const
|
|
158
|
-
|
|
171
|
+
const kit = core.match(/^kit ([A-D](?:\+[A-D])*) · (.+)$/);
|
|
172
|
+
if (kit) { const labels = kit[1].split('+'), kits = kit[2].split(' + ').map((k) => k.split(/\s+/)); return { kind: 'kit', label: labels[0], labels, ids: kits[0], kits, reply: tag }; }
|
|
173
|
+
const many = core.match(/^([A-D](?:\+[A-D])*) · (.+)$/);
|
|
174
|
+
if (!many) return null;
|
|
175
|
+
const labels = many[1].split('+');
|
|
176
|
+
const picks = many[2].split(' · ').map((part, i) => {
|
|
177
|
+
const st = part.trim().match(/^structure "(.+)"$/);
|
|
178
|
+
if (st) return { label: labels[i], structure: st[1], set: [] };
|
|
179
|
+
const m = part.trim().match(/^(\S+)(.*)$/);
|
|
180
|
+
return { label: labels[i], id: m[1], set: [...m[2].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]) };
|
|
181
|
+
});
|
|
182
|
+
// one pick keeps the original shape (label, id, set); several add `picks`
|
|
183
|
+
return { kind: 'resource', label: picks[0].label, id: picks[0].id, set: picks[0].set, labels, picks, reply: tag };
|
|
159
184
|
}
|
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) {
|
|
@@ -110,19 +110,29 @@ export function llmsTxt(items, site, { full = false } = {}) {
|
|
|
110
110
|
## The flow (for agents)
|
|
111
111
|
|
|
112
112
|
1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
|
|
113
|
-
ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`.
|
|
113
|
+
ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`. Agents load
|
|
114
|
+
skills and MCP servers when a session starts, so after installing the skills **read
|
|
115
|
+
\`.claude/skills/make-video/SKILL.md\` now** (or \`.agents/skills/make-video/SKILL.md\`) and follow it in this
|
|
116
|
+
session; tell the user that the MCP at ${site}/mcp, if they want it, is added before opening a session.
|
|
114
117
|
2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
|
|
115
118
|
line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
|
|
116
119
|
"no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
|
|
117
|
-
Mirror it back before proposing anything.
|
|
120
|
+
Mirror it back before proposing anything. **You never choose a look for the user.** These decisions always go
|
|
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 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
|
|
125
|
+
board only when the brand folder already settles that decision, and say so.
|
|
118
126
|
3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
|
|
119
127
|
this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
|
|
120
128
|
(format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
|
|
121
129
|
with the board in the URL fragment otherwise. The user opens it, sees the options live in their look, may tune one,
|
|
122
|
-
and chooses. The
|
|
123
|
-
\`
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
and chooses. The command keeps running and prints the choice the moment they choose (10 min by default;
|
|
131
|
+
\`--no-wait\` returns at once and they paste the line instead). It is one line: \`paper-motion: B · mesh-sage --set
|
|
132
|
+
speed=0.8 (bg-1)\`, \`paper-motion: A+C · type-on · scramble (text-1)\` when the board let them pick several
|
|
133
|
+
(\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
|
|
134
|
+
reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
|
|
135
|
+
they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
|
|
126
136
|
4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
|
|
127
137
|
\`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
|
|
128
138
|
fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
|
|
@@ -130,7 +140,8 @@ export function llmsTxt(items, site, { full = false } = {}) {
|
|
|
130
140
|
the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
|
|
131
141
|
\`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
|
|
132
142
|
from \`tokens.json\` \`brand\`: write those from the interview before rendering.
|
|
133
|
-
5. **Make the video.**
|
|
143
|
+
5. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
|
|
144
|
+
a recipe's own moves are placeholders to swap for theirs. Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
|
|
134
145
|
\`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
|
|
135
146
|
|
|
136
147
|
## Choosing well: the metadata
|
|
@@ -146,7 +157,13 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
146
157
|
reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
|
|
147
158
|
installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
|
|
148
159
|
texture first; the color comes from the palette.
|
|
149
|
-
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats
|
|
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.
|
|
150
167
|
|
|
151
168
|
\`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
|
|
152
169
|
sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
|
|
@@ -163,7 +180,7 @@ one-line \`--note\` for each; share the link.
|
|
|
163
180
|
|
|
164
181
|
The same registry as tools, with no install: add \`${site}/mcp\` (streamable HTTP, no auth) to your agent. Tools:
|
|
165
182
|
\`search\` (the filters above), \`resource\` (one record in full), \`recolor\` (a background's values in a palette),
|
|
166
|
-
\`board\` (creates a short link and
|
|
183
|
+
\`board\` (creates a short link and waits for the user's choice, which it returns as \`choices\`) and \`board_choice\`. Installing is
|
|
167
184
|
still \`npx paper-motion add\` in the user's project, or your own file tools from the record.
|
|
168
185
|
|
|
169
186
|
## The board link format
|
|
@@ -172,7 +189,7 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
|
|
|
172
189
|
|
|
173
190
|
\`\`\`json
|
|
174
191
|
{ "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
|
|
175
|
-
"reply": "bg-1",
|
|
192
|
+
"reply": "bg-1", "choose": 1,
|
|
176
193
|
"look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
|
|
177
194
|
"options": [
|
|
178
195
|
{ "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
|
|
@@ -181,10 +198,19 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
|
|
|
181
198
|
{ "label": "D", "id": "grain-slate", "values": {} } ] }
|
|
182
199
|
\`\`\`
|
|
183
200
|
|
|
201
|
+
A recipe board may hold one option that is a structure of your own for this brief; if chosen, you build it from scratch:
|
|
202
|
+
|
|
203
|
+
\`\`\`json
|
|
204
|
+
{ "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
|
|
205
|
+
{ "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
|
|
206
|
+
{ "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
|
|
207
|
+
{ "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action" } ] } }
|
|
208
|
+
\`\`\`
|
|
209
|
+
|
|
184
210
|
Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
|
|
185
211
|
answers 204 until the user chooses, then \`{ line, label }\`. Boards expire after 90 days; \`DELETE /api/boards/<id>\` forgets one.
|
|
186
212
|
|
|
187
|
-
Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
|
|
213
|
+
Rules: 2–4 options, labels A–D, \`choose\` 1 (default), a count or \`[min, max]\`, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
|
|
188
214
|
\`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
|
|
189
215
|
defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
|
|
190
216
|
|
|
@@ -234,7 +260,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
|
|
|
234
260
|
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; }
|
|
235
261
|
const index = {
|
|
236
262
|
name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
|
|
237
|
-
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)) })),
|
|
263
|
+
items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, 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, beats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
|
|
238
264
|
};
|
|
239
265
|
fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
|
|
240
266
|
fs.mkdirSync(path.join(out, 'files'), { recursive: true });
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
}
|