paper-motion 0.2.5 → 0.2.7
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 +13 -0
- package/bin/pamo.mjs +8 -4
- package/lib/customize.mjs +21 -7
- package/lib/live-board.js +1 -1
- package/lib/registry.mjs +13 -7
- 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,8 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
+
## 0.2.7 · 2026-11-10
|
|
6
|
+
|
|
7
|
+
- A new resource type, **blocks**: 29 ready-made 4–8 s moments (annotations, interfaces, code, data, type, brand, effects), installed like recipes and customized through their `CONFIG`; each recomposes for 16:9, 9:16 and 1:1.
|
|
8
|
+
- `pamo registry previews` renders blocks; `pamo add --set` writes their values into `CONFIG`.
|
|
9
|
+
|
|
10
|
+
## 0.2.6 · 2026-11-10
|
|
11
|
+
|
|
12
|
+
- `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).
|
|
13
|
+
- 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.
|
|
14
|
+
- `scramble` measures letters by layout width, so it works inside a 3D world.
|
|
15
|
+
|
|
5
16
|
## 0.2.5 · unreleased
|
|
6
17
|
|
|
18
|
+
- 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`.
|
|
19
|
+
- Three recipes: `manifesto` (words only, three statements and a turn), `three-things` (three cards, three mechanics) and `before-after` (two states, one wipe).
|
|
7
20
|
- 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
21
|
|
|
9
22
|
## 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', 'block'].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, block: 2.5 };
|
|
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' || it.type === 'block') {
|
|
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
|
-
|
|
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
|
@@ -138,11 +138,18 @@ function fontTokens(item, values) {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/** A recipe's CONFIG object from flat values (proof1Title… → proof[0].title, "1920x1080" → [1920, 1080]). */
|
|
141
|
+
// resources whose file is a whole scene, customized through its CONFIG
|
|
142
|
+
const SCENE_TYPES = ['recipe', 'shot', 'block'];
|
|
143
|
+
|
|
141
144
|
function recipeConfig(values) {
|
|
142
145
|
const cfg = {}, groups = {};
|
|
143
146
|
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; }
|
|
147
|
+
const m = k.match(/^(proof|step|shot|thing)(\d)(Label|Title|Body|Image|Caption)$/);
|
|
148
|
+
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; }
|
|
149
|
+
const ba = k.match(/^(before|after)(Tag|Line|Image)$/);
|
|
150
|
+
if (ba) { (cfg[ba[1]] ??= {})[ba[2].toLowerCase()] = v; continue; }
|
|
151
|
+
const st = k.match(/^statement(\d)$/);
|
|
152
|
+
if (st) { (cfg.statements ??= [])[st[1] - 1] = v; continue; }
|
|
146
153
|
if (k === 'format') cfg.format = String(v).split('x').map(Number);
|
|
147
154
|
else cfg[k] = v === '' ? null : v;
|
|
148
155
|
}
|
|
@@ -154,10 +161,17 @@ function recipeScene(content, values) {
|
|
|
154
161
|
const start = content.indexOf('const CONFIG = {');
|
|
155
162
|
if (start < 0) return content;
|
|
156
163
|
const end = content.indexOf('\n};', start) + 3;
|
|
157
|
-
|
|
164
|
+
// the original entries, by key (an entry runs until the next top-level key), so settings that are not params
|
|
165
|
+
// (backgroundOpacity, turnWord, a card's label…) survive a --set
|
|
166
|
+
const original = content.slice(start + 'const CONFIG = {\n'.length, end - 3).split(/\n(?= \w+: )/);
|
|
167
|
+
const kept = new Map(original.map((e) => [e.match(/^ (\w+):/)?.[1], e]).filter(([k]) => k));
|
|
168
|
+
const render = (k, v) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
|
|
158
169
|
? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
|
|
159
|
-
: ` ${k}: ${v === null ? 'null' : literal(v)},`)
|
|
160
|
-
|
|
170
|
+
: ` ${k}: ${v === null ? 'null' : literal(v)},`);
|
|
171
|
+
const lines = [];
|
|
172
|
+
for (const [k, e] of kept) lines.push(k in cfg ? render(k, cfg[k]) : e);
|
|
173
|
+
for (const [k, v] of Object.entries(cfg)) if (!kept.has(k)) lines.push(render(k, v));
|
|
174
|
+
return `${content.slice(0, start)}const CONFIG = {\n${lines.join('\n')}\n};${content.slice(end)}`;
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
// ── the public API ──────────────────────────────────────────────
|
|
@@ -171,7 +185,7 @@ export function apply(item, values) {
|
|
|
171
185
|
const files = (item.files ?? []).map((f) => {
|
|
172
186
|
if (f.content === undefined) return f;
|
|
173
187
|
if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
|
|
174
|
-
if (item.type
|
|
188
|
+
if (SCENE_TYPES.includes(item.type) && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
|
|
175
189
|
return f;
|
|
176
190
|
});
|
|
177
191
|
let entry = item.install?.entry;
|
|
@@ -216,7 +230,7 @@ export function code(item, values, { showAll = false, site = 'https://paper-moti
|
|
|
216
230
|
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
|
|
217
231
|
{ 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
232
|
];
|
|
219
|
-
} else if (item.type
|
|
233
|
+
} else if (SCENE_TYPES.includes(item.type)) {
|
|
220
234
|
const scene = out.files.find((f) => /scene\.html$/.test(f.path));
|
|
221
235
|
tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
|
|
222
236
|
}
|
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', 'block'];
|
|
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', 'block'];
|
|
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', block: 'Blocks' };
|
|
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,10 @@ 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
|
|
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) · blocks for the moments inside it (ready-made 4–8 s pieces: annotations on a screen, a
|
|
125
|
+
form, a terminal, code typing, a chart, captions, a logo sting; each recomposes for 16:9, 9:16 and 1:1). Skip a
|
|
123
126
|
board only when the brand folder already settles that decision, and say so.
|
|
124
127
|
3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
|
|
125
128
|
this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
|
|
@@ -155,10 +158,13 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
|
|
|
155
158
|
reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
|
|
156
159
|
installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
|
|
157
160
|
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
|
|
160
|
-
one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
|
|
161
|
-
then build from scratch.
|
|
161
|
+
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes, shots and blocks: \`duration\`, \`formats\` and \`beats\` (the structure as a
|
|
162
|
+
timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
|
|
163
|
+
name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
|
|
164
|
+
format below), which you then build from scratch. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
|
|
165
|
+
order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
|
|
166
|
+
reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
|
|
167
|
+
the reference for its block.
|
|
162
168
|
|
|
163
169
|
\`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
|
|
164
170
|
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
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
|
}
|