paper-motion 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/bin/pamo.mjs +664 -0
- package/examples/breath/brief.md +11 -0
- package/examples/breath/poster.jpg +0 -0
- package/examples/breath/scene.html +59 -0
- package/examples/chrome-endcard/brief.md +7 -0
- package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/chrome-endcard/poster.jpg +0 -0
- package/examples/chrome-endcard/scene.html +68 -0
- package/examples/chrome-endcard/wordmark.js +16 -0
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/install-loop/brief.md +7 -0
- package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/install-loop/poster.jpg +0 -0
- package/examples/install-loop/scene.html +46 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/examples/signal/brief.md +10 -0
- package/examples/signal/poster.jpg +0 -0
- package/examples/signal/scene.html +50 -0
- package/lib/board.mjs +95 -0
- package/lib/browser.mjs +88 -0
- package/lib/catalog.mjs +77 -0
- package/lib/customize.mjs +162 -0
- package/lib/keys.mjs +93 -0
- package/lib/live-board.js +128 -0
- package/lib/modules.mjs +23 -0
- package/lib/react-hooks.mjs +9 -0
- package/lib/registry.mjs +344 -0
- package/lib/render.mjs +193 -0
- package/lib/server.mjs +89 -0
- package/lib/sfx.mjs +287 -0
- package/lib/shaderset.mjs +65 -0
- package/lib/wordmark.mjs +64 -0
- package/package.json +46 -4
- package/runtime/brand.js +71 -0
- package/runtime/core.js +269 -0
- package/runtime/index.js +349 -0
- package/runtime/jsx-shim.js +3 -0
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +94 -0
- package/runtime/player.js +69 -0
- package/runtime/react-shim.js +15 -0
- package/templates/blank/brief.md +39 -0
- package/templates/blank/scene.html +44 -0
- package/templates/brand-sheet/index.html +241 -0
- package/templates/move-demo/brief.md +4 -0
- package/templates/move-demo/scene.html +158 -0
- package/templates/piece/brief.md +39 -0
- package/templates/piece/scene.html +62 -0
- package/templates/shader-set/shot.js +47 -0
- package/templates/still/brief.md +3 -0
- package/templates/still/scene.html +56 -0
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Static server for scenes. Injects an import map so scenes can
|
|
2
|
+
// `import { scene } from 'paper-motion'` with no bundler, and live-reloads
|
|
3
|
+
// previews when files change.
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { moduleDir } from './modules.mjs';
|
|
9
|
+
|
|
10
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
|
|
12
|
+
const MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
14
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
15
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif',
|
|
16
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
|
|
17
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.map': 'application/json',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const IMPORT_MAP = {
|
|
21
|
+
imports: {
|
|
22
|
+
'paper-motion': '/__pm/runtime/index.js',
|
|
23
|
+
'paper-motion/moves': '/__pm/runtime/moves.js',
|
|
24
|
+
'paper-motion/kit': '/__pm/runtime/kit.js',
|
|
25
|
+
'paper-motion/brand': '/__pm/runtime/brand.js',
|
|
26
|
+
'@paper-design/shaders': '/__pm/node_modules/@paper-design/shaders/dist/index.js',
|
|
27
|
+
'@paper-design/shaders-react': '/__pm/node_modules/@paper-design/shaders-react/dist/index.js',
|
|
28
|
+
react: '/__pm/runtime/react-shim.js',
|
|
29
|
+
'react/jsx-runtime': '/__pm/runtime/jsx-shim.js',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const RELOAD_CLIENT = `<script>(()=>{if(location.search.includes('render'))return;
|
|
34
|
+
const es=new EventSource('/__pm/events');es.onmessage=()=>location.reload();})();</script>`;
|
|
35
|
+
|
|
36
|
+
export function startServer({ root, port = 0, watch = false }) {
|
|
37
|
+
const clients = new Set();
|
|
38
|
+
const server = http.createServer((req, res) => {
|
|
39
|
+
const url = new URL(req.url, 'http://x');
|
|
40
|
+
let p = decodeURIComponent(url.pathname);
|
|
41
|
+
if (p === '/__pm/events') {
|
|
42
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
43
|
+
res.write(': ok\n\n');
|
|
44
|
+
clients.add(res);
|
|
45
|
+
req.on('close', () => clients.delete(res));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (p === '/favicon.ico') { res.writeHead(204).end(); return; }
|
|
49
|
+
let file;
|
|
50
|
+
if (p.startsWith('/__pm/runtime/')) file = path.join(ENGINE, 'runtime', p.slice('/__pm/runtime/'.length));
|
|
51
|
+
let base = p.startsWith('/__pm/') ? ENGINE : root;
|
|
52
|
+
if (p.startsWith('/__pm/node_modules/')) {
|
|
53
|
+
// '/__pm/node_modules/@scope/pkg/dist/x.js' → wherever npm put @scope/pkg
|
|
54
|
+
const rest = p.slice('/__pm/node_modules/'.length).split('/'), n = rest[0].startsWith('@') ? 2 : 1;
|
|
55
|
+
base = moduleDir(rest.slice(0, n).join('/')) ?? path.join(ENGINE, 'node_modules', ...rest.slice(0, n));
|
|
56
|
+
file = path.join(base, ...rest.slice(n));
|
|
57
|
+
} else if (!file) file = path.join(root, p);
|
|
58
|
+
if (!path.resolve(file).startsWith(path.resolve(base))) { res.writeHead(403).end(); return; }
|
|
59
|
+
fs.stat(file, (err, st) => {
|
|
60
|
+
if (!err && st.isDirectory()) { file = path.join(file, 'index.html'); }
|
|
61
|
+
fs.readFile(file, (e, buf) => {
|
|
62
|
+
if (e) { res.writeHead(404, { 'content-type': 'text/plain' }).end(`not found: ${p}`); return; }
|
|
63
|
+
const ext = path.extname(file).toLowerCase();
|
|
64
|
+
const headers = { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': 'no-store', 'access-control-allow-origin': '*' };
|
|
65
|
+
if (ext === '.html') {
|
|
66
|
+
let html = buf.toString('utf8');
|
|
67
|
+
const inject = `<script type="importmap">${JSON.stringify(IMPORT_MAP)}</script>${watch ? RELOAD_CLIENT : ''}`;
|
|
68
|
+
html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + inject) : inject + html;
|
|
69
|
+
res.writeHead(200, headers).end(html);
|
|
70
|
+
} else res.writeHead(200, headers).end(buf);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (watch) {
|
|
76
|
+
let timer;
|
|
77
|
+
try {
|
|
78
|
+
fs.watch(root, { recursive: true }, (_e, f) => {
|
|
79
|
+
if (!f || /(^|[\\/])(node_modules|renders|stills|\.git)([\\/]|$)/.test(f) || /\.(mp4|mov|webm|gif)$/.test(f)) return;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
timer = setTimeout(() => { for (const c of clients) c.write('data: reload\n\n'); }, 120);
|
|
82
|
+
});
|
|
83
|
+
} catch { /* recursive watch unsupported: no live reload */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
server.listen(port, '127.0.0.1', () => resolve({ server, port: server.address().port, close: () => new Promise((r) => { for (const c of clients) c.end(); server.close(r); }) }));
|
|
88
|
+
});
|
|
89
|
+
}
|
package/lib/sfx.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Deterministic sound-effects synthesizer. Scenes declare cues with
|
|
2
|
+
// s.sfx(t, type, opts); the renderer synthesizes them into a WAV and muxes it.
|
|
3
|
+
// Everything is generated from code (no samples) and seeded, so the same scene
|
|
4
|
+
// always produces the same audio.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { loadBank } from './keys.mjs';
|
|
7
|
+
|
|
8
|
+
const SR = 48000;
|
|
9
|
+
|
|
10
|
+
function mulberry32(seed) {
|
|
11
|
+
let a = seed >>> 0;
|
|
12
|
+
return () => {
|
|
13
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
14
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
15
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// RBJ biquad
|
|
21
|
+
class Biquad {
|
|
22
|
+
constructor(type, freq, q = 0.707, gainDb = 0) {
|
|
23
|
+
const w = (2 * Math.PI * Math.min(freq, SR * 0.45)) / SR, cos = Math.cos(w), alpha = Math.sin(w) / (2 * q);
|
|
24
|
+
const A = 10 ** (gainDb / 40);
|
|
25
|
+
let b0, b1, b2, a0, a1, a2;
|
|
26
|
+
a0 = 1 + alpha; a1 = -2 * cos; a2 = 1 - alpha;
|
|
27
|
+
if (type === 'lowpass') { b0 = (1 - cos) / 2; b1 = 1 - cos; b2 = b0; }
|
|
28
|
+
else if (type === 'highpass') { b0 = (1 + cos) / 2; b1 = -(1 + cos); b2 = b0; }
|
|
29
|
+
else if (type === 'peak') { b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 - alpha * A; a0 = 1 + alpha / A; a2 = 1 - alpha / A; }
|
|
30
|
+
else if (type === 'highshelf') {
|
|
31
|
+
const sq = 2 * Math.sqrt(A) * alpha;
|
|
32
|
+
b0 = A * ((A + 1) + (A - 1) * cos + sq); b1 = -2 * A * ((A - 1) + (A + 1) * cos); b2 = A * ((A + 1) + (A - 1) * cos - sq);
|
|
33
|
+
a0 = (A + 1) - (A - 1) * cos + sq; a1 = 2 * ((A - 1) - (A + 1) * cos); a2 = (A + 1) - (A - 1) * cos - sq;
|
|
34
|
+
}
|
|
35
|
+
else { b0 = alpha; b1 = 0; b2 = -alpha; } // bandpass, 0 dB peak
|
|
36
|
+
this.b = [b0 / a0, b1 / a0, b2 / a0]; this.a = [a1 / a0, a2 / a0];
|
|
37
|
+
this.x1 = this.x2 = this.y1 = this.y2 = 0;
|
|
38
|
+
}
|
|
39
|
+
process(x) {
|
|
40
|
+
const y = this.b[0] * x + this.b[1] * this.x1 + this.b[2] * this.x2 - this.a[0] * this.y1 - this.a[1] * this.y2;
|
|
41
|
+
this.x2 = this.x1; this.x1 = x; this.y2 = this.y1; this.y1 = y;
|
|
42
|
+
return y;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TAU = Math.PI * 2;
|
|
47
|
+
const smooth = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Voices. Each returns { len (s), fn(i, sr) -> sample } — mono; panning is applied by the mixer.
|
|
51
|
+
* All of them are dark by design; the master low-pass (default 2 kHz) guarantees no harsh highs.
|
|
52
|
+
*/
|
|
53
|
+
const VOICES = {
|
|
54
|
+
// Continuous low bed. { dur, freq=55, gain=0.05, attack=3, release=4 }
|
|
55
|
+
drone({ dur = 10, freq = 55, gain = 0.05, attack = 3, release = 4 }, rnd) {
|
|
56
|
+
const lp = new Biquad('lowpass', 260), ph = [rnd(), rnd(), rnd(), rnd()].map((p) => p * TAU);
|
|
57
|
+
return { len: dur, fn: (i) => {
|
|
58
|
+
const t = i / SR;
|
|
59
|
+
const env = smooth(t / attack) * smooth((dur - t) / release);
|
|
60
|
+
const am = 1 + 0.18 * Math.sin(TAU * 0.07 * t + ph[0]);
|
|
61
|
+
const tone = Math.sin(TAU * freq * t + ph[1]) + 0.5 * Math.sin(TAU * freq * 1.5 * t + ph[2]) + 0.3 * Math.sin(TAU * freq * 2 * t + ph[3]);
|
|
62
|
+
const air = lp.process(rnd() * 2 - 1) * 0.9;
|
|
63
|
+
return gain * env * (tone * 0.6 * am + air);
|
|
64
|
+
} };
|
|
65
|
+
},
|
|
66
|
+
// Slow chord pad: root, fifth, octave. { dur=4, freq=73.4, gain=0.1 }
|
|
67
|
+
swell({ dur = 4, freq = 73.4, gain = 0.1 }) {
|
|
68
|
+
const parts = [[1, 1], [1.5, 0.55], [2, 0.4], [1.003, 0.6], [1.497, 0.3]];
|
|
69
|
+
return { len: dur, fn: (i) => {
|
|
70
|
+
const t = i / SR, p = t / dur;
|
|
71
|
+
const env = p < 0.45 ? Math.sin((p / 0.45) * Math.PI / 2) ** 2 : Math.cos(((p - 0.45) / 0.55) * Math.PI / 2) ** 2;
|
|
72
|
+
let s = 0; for (const [r, a] of parts) s += a * Math.sin(TAU * freq * r * t);
|
|
73
|
+
return gain * env * s * 0.35;
|
|
74
|
+
} };
|
|
75
|
+
},
|
|
76
|
+
// Pen / pencil writing on paper: band-limited noise with stroke rhythm. { dur=1, gain=0.12, rate=7 }
|
|
77
|
+
pen({ dur = 1, gain = 0.12, rate = 7, tone = 520 }, rnd) {
|
|
78
|
+
const bp = new Biquad('bandpass', tone, 0.7), lp = new Biquad('lowpass', 1100);
|
|
79
|
+
const r = rate * (0.85 + 0.3 * rnd()), ph = rnd() * TAU;
|
|
80
|
+
return { len: dur, fn: (i) => {
|
|
81
|
+
const t = i / SR;
|
|
82
|
+
const env = smooth(t / 0.025) * smooth((dur - t) / 0.05);
|
|
83
|
+
const stroke = 0.35 + 0.65 * Math.abs(Math.sin(TAU * r * t + ph + 0.6 * Math.sin(TAU * 1.3 * t))) ** 1.6;
|
|
84
|
+
return gain * env * stroke * lp.process(bp.process(rnd() * 2 - 1)) * 2.2;
|
|
85
|
+
} };
|
|
86
|
+
},
|
|
87
|
+
// Paper movement / whoosh. { dur=0.8, gain=0.14 }
|
|
88
|
+
paper({ dur = 0.8, gain = 0.14 }, rnd) {
|
|
89
|
+
const lp = new Biquad('lowpass', 650), hp = new Biquad('highpass', 90), lp2 = new Biquad('lowpass', 900);
|
|
90
|
+
return { len: dur, fn: (i) => {
|
|
91
|
+
const t = i / SR, p = t / dur;
|
|
92
|
+
const env = Math.sin(Math.PI * Math.min(1, p)) ** 2;
|
|
93
|
+
const crackle = rnd() < 0.002 ? (rnd() * 2 - 1) * 3 : 0;
|
|
94
|
+
return gain * (env * hp.process(lp.process(rnd() * 2 - 1)) * 1.8 + env * lp2.process(crackle) * 0.6);
|
|
95
|
+
} };
|
|
96
|
+
},
|
|
97
|
+
// Soft low stamp / body hit. { freq=62, gain=0.5, decay=0.45 }
|
|
98
|
+
thud({ freq = 62, gain = 0.5, decay = 0.45 }, rnd) {
|
|
99
|
+
const lp = new Biquad('lowpass', 320);
|
|
100
|
+
let phase = 0;
|
|
101
|
+
return { len: decay * 5, fn: (i) => {
|
|
102
|
+
const t = i / SR;
|
|
103
|
+
const f = freq * (1 + 0.8 * Math.exp(-t / 0.035));
|
|
104
|
+
phase += (TAU * f) / SR;
|
|
105
|
+
const env = smooth(t / 0.004) * Math.exp(-t / decay);
|
|
106
|
+
const click = lp.process((rnd() * 2 - 1) * Math.exp(-t / 0.012));
|
|
107
|
+
return gain * (env * Math.sin(phase) + click * 0.5);
|
|
108
|
+
} };
|
|
109
|
+
},
|
|
110
|
+
// Wooden knock, low. { freq=140, gain=0.3, decay=0.16 }
|
|
111
|
+
knock({ freq = 140, gain = 0.3, decay = 0.16 }, rnd) {
|
|
112
|
+
const lp = new Biquad('lowpass', 520);
|
|
113
|
+
return { len: decay * 6, fn: (i) => {
|
|
114
|
+
const t = i / SR;
|
|
115
|
+
const a = smooth(t / 0.002);
|
|
116
|
+
const body = Math.sin(TAU * freq * t) * Math.exp(-t / decay) + 0.35 * Math.sin(TAU * freq * 2.31 * t) * Math.exp(-t / (decay * 0.4));
|
|
117
|
+
const n = lp.process((rnd() * 2 - 1) * Math.exp(-t / 0.008));
|
|
118
|
+
return gain * a * (body + n * 0.6);
|
|
119
|
+
} };
|
|
120
|
+
},
|
|
121
|
+
// Tiny low tick (UI-ish but dull). { freq=200, gain=0.1, decay=0.05 }
|
|
122
|
+
tick({ freq = 200, gain = 0.1, decay = 0.05 }) {
|
|
123
|
+
return { len: decay * 6, fn: (i) => { const t = i / SR; return gain * smooth(t / 0.0015) * Math.exp(-t / decay) * Math.sin(TAU * freq * t); } };
|
|
124
|
+
},
|
|
125
|
+
// A keystroke, modeled like a real one: the bottom-out impact excites a few inharmonic keycap/plate
|
|
126
|
+
// resonances plus a low case "thock"; a softer, higher upstroke follows; a hint of room around it.
|
|
127
|
+
// { gain=0.3, kind='letter'|'space'|'enter', vel=1, style='creamy'|'thock'|'clack' }. Seeded variation.
|
|
128
|
+
key({ gain = 0.3, kind = 'letter', vel = 1, style = 'creamy', bank }, rnd) {
|
|
129
|
+
if (bank) {
|
|
130
|
+
// Real keystrokes: pick a hit from the bank, vary level and pitch slightly so no two repeat exactly.
|
|
131
|
+
const pool = (kind === 'space' || kind === 'enter') ? bank.heavy : bank.letter;
|
|
132
|
+
const src = pool[Math.floor(rnd() * pool.length) % pool.length];
|
|
133
|
+
const rate = 0.98 + 0.04 * rnd(), g = gain * vel * (0.82 + 0.3 * rnd()) * (kind === 'enter' ? 1.15 : 1);
|
|
134
|
+
const len = src.length / rate / SR;
|
|
135
|
+
return { len, bright: true, fn: (i) => {
|
|
136
|
+
const p = i * rate, j = Math.floor(p), x = p - j;
|
|
137
|
+
const y0 = src[j - 1] ?? 0, y1 = src[j] ?? 0, y2 = src[j + 1] ?? 0, y3 = src[j + 2] ?? 0;
|
|
138
|
+
const soft = Math.min(1, i / (0.0035 * SR)); // a 3.5 ms rise rounds off the hardest edge of the click
|
|
139
|
+
return g * soft * (y1 + 0.5 * x * (y2 - y0 + x * (2 * y0 - 5 * y1 + 4 * y2 - y3 + x * (3 * (y1 - y2) + y3 - y0))));
|
|
140
|
+
} };
|
|
141
|
+
}
|
|
142
|
+
const S = {
|
|
143
|
+
thock: { modes: [[620, 0.016, 1], [1040, 0.011, 0.7], [1580, 0.008, 0.45], [2350, 0.006, 0.25]], body: [170, 0.026, 1.1], burst: 0.0009, up: 0.32 },
|
|
144
|
+
creamy: { modes: [[780, 0.013, 1], [1290, 0.010, 0.75], [1960, 0.007, 0.5], [2900, 0.005, 0.3]], body: [210, 0.020, 0.8], burst: 0.0007, up: 0.4 },
|
|
145
|
+
clack: { modes: [[1150, 0.010, 0.8], [1850, 0.008, 1], [2750, 0.006, 0.7], [3900, 0.004, 0.4]], body: [260, 0.014, 0.5], burst: 0.0005, up: 0.5 },
|
|
146
|
+
}[style] ?? null;
|
|
147
|
+
if (!S) throw new Error(`key style "${style}": use creamy, thock or clack`);
|
|
148
|
+
const heavy = kind === 'space' || kind === 'enter';
|
|
149
|
+
const pitch = (heavy ? (kind === 'space' ? 0.72 : 0.84) : 1) * (0.93 + 0.14 * rnd());
|
|
150
|
+
const len = 0.26, N = Math.ceil(len * SR), buf = new Float32Array(N);
|
|
151
|
+
// one strike: noise burst + click into a bank of ringing band-passes (Q from the decay time)
|
|
152
|
+
const strike = (at, amp, fMul) => {
|
|
153
|
+
const bank = S.modes.map(([f, tau, g]) => {
|
|
154
|
+
const fr = f * pitch * fMul * (0.96 + 0.08 * rnd());
|
|
155
|
+
return { bq: new Biquad('bandpass', fr, Math.max(2, Math.PI * fr * tau * (heavy ? 1.3 : 1))), g: g * (0.8 + 0.4 * rnd()) };
|
|
156
|
+
});
|
|
157
|
+
const [bf, btau, bg] = S.body;
|
|
158
|
+
const body = new Biquad('bandpass', bf * pitch * (0.95 + 0.1 * rnd()), Math.PI * bf * btau * (heavy ? 1.5 : 1));
|
|
159
|
+
const hp = new Biquad('highpass', 400);
|
|
160
|
+
const i0 = Math.round(at * SR), burst = S.burst * (heavy ? 1.6 : 1);
|
|
161
|
+
for (let i = i0; i < N; i++) {
|
|
162
|
+
const t = (i - i0) / SR;
|
|
163
|
+
const ex = t < burst * 6 ? (rnd() * 2 - 1) * Math.exp(-t / burst) + (i === i0 ? 3 : 0) : 0;
|
|
164
|
+
let y = 0;
|
|
165
|
+
for (const m of bank) y += m.bq.process(ex) * m.g;
|
|
166
|
+
y += body.process(ex) * bg * 1.6;
|
|
167
|
+
y += hp.process(ex) * 0.12; // the contact "tick" itself
|
|
168
|
+
buf[i] += amp * y;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const pre = 0.004 + 0.008 * rnd(); // finger meets the cap
|
|
172
|
+
strike(0, 0.08, 1.35);
|
|
173
|
+
strike(pre, 1, 1); // bottom-out
|
|
174
|
+
if (kind === 'space') strike(pre + 0.007 + 0.005 * rnd(), 0.35, 1.25); // stabilizer rattle
|
|
175
|
+
strike(pre + (heavy ? 0.11 : 0.075) + 0.035 * rnd(), S.up * (0.8 + 0.4 * rnd()), 1.18); // upstroke
|
|
176
|
+
// a little room: two early reflections
|
|
177
|
+
for (const [d, k] of [[0.0071, 0.16], [0.0133, 0.09]]) { const o = Math.round(d * SR); for (let i = N - 1; i >= o; i--) buf[i] += buf[i - o] * k; }
|
|
178
|
+
let peak = 1e-9; for (let i = 0; i < N; i++) peak = Math.max(peak, Math.abs(buf[i]));
|
|
179
|
+
const g = (gain * vel * (0.8 + 0.3 * rnd()) * (heavy ? 1.2 : 1)) / peak;
|
|
180
|
+
return { len, bright: true, fn: (i) => buf[i] * g };
|
|
181
|
+
},
|
|
182
|
+
// Air moving past: a pass-by whoosh. The band sweeps from→to→back while the level peaks at `peak`.
|
|
183
|
+
// { dur=0.7, gain=0.2, from=320, to=1200, peak=0.6, body=0 (low weight under it, 0..1) }
|
|
184
|
+
whoosh({ dur = 0.7, gain = 0.2, from = 320, to = 1200, peak = 0.6, body = 0 }, rnd) {
|
|
185
|
+
// state-variable filters so the centre frequency can move every sample
|
|
186
|
+
const svf = () => { let low = 0, band = 0; return (x, fc, q) => { const f = 2 * Math.sin(Math.PI * Math.min(fc, SR / 6) / SR); low += f * band; const high = x - low - q * band; band += f * high; return band; }; };
|
|
187
|
+
const A = svf(), B = svf();
|
|
188
|
+
let pink = 0, flut = 0;
|
|
189
|
+
const ph = rnd() * TAU, bf = 52 + 14 * rnd();
|
|
190
|
+
return { len: dur, fn: (i) => {
|
|
191
|
+
const t = i / SR, p = t / dur;
|
|
192
|
+
const env = p < peak ? smooth(p / peak) ** 1.6 : Math.cos(((p - peak) / (1 - peak)) * Math.PI / 2) ** 2;
|
|
193
|
+
const fc = p < peak ? from * (to / from) ** smooth(p / peak) : to * ((from * 0.8) / to) ** ((p - peak) / (1 - peak));
|
|
194
|
+
const w = rnd() * 2 - 1;
|
|
195
|
+
pink = 0.8 * pink + 0.2 * w; // gentle tilt, still airy
|
|
196
|
+
flut = 0.9995 * flut + 0.0005 * (rnd() * 2 - 1);
|
|
197
|
+
const n = pink * 1.6;
|
|
198
|
+
const air = A(n, fc, 0.9) + 0.5 * B(n, fc * 1.6, 1.1);
|
|
199
|
+
const low = body * Math.sin(TAU * bf * t + ph) * env * 0.5;
|
|
200
|
+
return gain * env * (air * (1 + flut * 60) * 1.4 + low);
|
|
201
|
+
} };
|
|
202
|
+
},
|
|
203
|
+
// A small card/paper flick (something light landing). { gain=0.12 }
|
|
204
|
+
flick({ gain = 0.12 }, rnd) {
|
|
205
|
+
const bp = new Biquad('bandpass', 700 + 500 * rnd(), 0.9), lp = new Biquad('lowpass', 1600);
|
|
206
|
+
const f = 160 + 80 * rnd();
|
|
207
|
+
return { len: 0.12, fn: (i) => {
|
|
208
|
+
const t = i / SR;
|
|
209
|
+
const n = lp.process(bp.process(rnd() * 2 - 1)) * Math.exp(-t / 0.012) * 2.4;
|
|
210
|
+
const k = Math.sin(TAU * f * t) * Math.exp(-t / 0.02) * smooth(t / 0.001) * 0.5;
|
|
211
|
+
return gain * (0.75 + 0.5 * rnd()) * (n + k);
|
|
212
|
+
} };
|
|
213
|
+
},
|
|
214
|
+
// Pitch glide. { dur=0.6, from=160, to=80, gain=0.08 }
|
|
215
|
+
sweep({ dur = 0.6, from = 160, to = 80, gain = 0.08 }) {
|
|
216
|
+
let phase = 0;
|
|
217
|
+
return { len: dur, fn: (i) => {
|
|
218
|
+
const t = i / SR, p = t / dur;
|
|
219
|
+
phase += (TAU * (from + (to - from) * p)) / SR;
|
|
220
|
+
return gain * Math.sin(Math.PI * p) ** 2 * Math.sin(phase);
|
|
221
|
+
} };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const SFX_TYPES = Object.keys(VOICES);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param cues [{ t, type, pan?, seed?, ...voiceOpts }]
|
|
229
|
+
* @param options { duration, lowpass=2000, highpass=30, gain=1, peak=0.8, normalize=true }
|
|
230
|
+
* normalize: true scales the mix up or down to `peak`; false keeps the cues' own gains and only
|
|
231
|
+
* limits overs (used when the scene brings its own audio, so effects sit where their gains put them)
|
|
232
|
+
* @returns { left: Float32Array, right: Float32Array, sampleRate }
|
|
233
|
+
*/
|
|
234
|
+
export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain = 1, peak = 0.8, normalize = true } = {}) {
|
|
235
|
+
const n = Math.ceil(duration * SR);
|
|
236
|
+
const L = new Float32Array(n), R = new Float32Array(n);
|
|
237
|
+
// Voices marked `bright` (keys) go through their own, gentler low-pass: a keystroke's click lives at 1–4 kHz.
|
|
238
|
+
const BL = new Float32Array(n), BR = new Float32Array(n);
|
|
239
|
+
cues.forEach((c, k) => {
|
|
240
|
+
const voice = VOICES[c.type];
|
|
241
|
+
if (!voice) throw new Error(`Unknown sfx type "${c.type}". Use: ${SFX_TYPES.join(', ')}`);
|
|
242
|
+
const rnd = mulberry32((c.seed ?? k * 7919 + 13) >>> 0);
|
|
243
|
+
const v = voice(typeof c.bank === 'string' ? { ...c, bank: loadBank(c.bank) } : c, rnd);
|
|
244
|
+
const start = Math.round(c.t * SR), len = Math.ceil(v.len * SR);
|
|
245
|
+
const pan = Math.max(-1, Math.min(1, c.pan ?? 0)), gl = Math.cos(((pan + 1) * Math.PI) / 4), gr = Math.sin(((pan + 1) * Math.PI) / 4);
|
|
246
|
+
for (let i = 0; i < len; i++) {
|
|
247
|
+
const j = start + i;
|
|
248
|
+
if (j < 0) continue; if (j >= n) break;
|
|
249
|
+
const s = v.fn(i);
|
|
250
|
+
if (v.bright) { BL[j] += s * gl; BR[j] += s * gr; } else { L[j] += s * gl; R[j] += s * gr; }
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// Master: 24 dB/oct low-pass (no harsh highs), high-pass rumble, normalize, soft clip.
|
|
254
|
+
for (const ch of [L, R]) {
|
|
255
|
+
const f = [new Biquad('lowpass', lowpass), new Biquad('lowpass', lowpass), new Biquad('highpass', highpass)];
|
|
256
|
+
for (let i = 0; i < n; i++) ch[i] = f[2].process(f[1].process(f[0].process(ch[i])));
|
|
257
|
+
}
|
|
258
|
+
// Keys bus: open on top (no low-pass), a little less box (~450 Hz), more presence and air above 4 kHz.
|
|
259
|
+
[[BL, L], [BR, R]].forEach(([b, ch]) => {
|
|
260
|
+
const f = [new Biquad('highpass', 110), new Biquad('peak', 450, 1.0, -3.5), new Biquad('highshelf', 4200, 0.7, 3.5)];
|
|
261
|
+
for (let i = 0; i < n; i++) ch[i] += f[2].process(f[1].process(f[0].process(b[i])));
|
|
262
|
+
});
|
|
263
|
+
let max = 1e-9;
|
|
264
|
+
for (let i = 0; i < n; i++) max = Math.max(max, Math.abs(L[i]), Math.abs(R[i]));
|
|
265
|
+
const k = (normalize ? peak / max : Math.min(1, peak / max)) * gain;
|
|
266
|
+
const fadeOut = Math.min(n, Math.round(0.05 * SR));
|
|
267
|
+
for (let i = 0; i < n; i++) {
|
|
268
|
+
const f = i > n - fadeOut ? (n - i) / fadeOut : 1;
|
|
269
|
+
L[i] = Math.tanh(L[i] * k * 1.1) * f; R[i] = Math.tanh(R[i] * k * 1.1) * f;
|
|
270
|
+
}
|
|
271
|
+
return { left: L, right: R, sampleRate: SR };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function writeWav(file, { left, right, sampleRate }) {
|
|
275
|
+
const n = left.length, buf = Buffer.alloc(44 + n * 4);
|
|
276
|
+
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 4, 4); buf.write('WAVE', 8);
|
|
277
|
+
buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
|
|
278
|
+
buf.writeUInt32LE(sampleRate, 24); buf.writeUInt32LE(sampleRate * 4, 28); buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34);
|
|
279
|
+
buf.write('data', 36); buf.writeUInt32LE(n * 4, 40);
|
|
280
|
+
const rnd = mulberry32(1);
|
|
281
|
+
for (let i = 0; i < n; i++) {
|
|
282
|
+
const d = (rnd() - rnd()) / 32768; // TPDF dither
|
|
283
|
+
buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round((left[i] + d) * 32767))), 44 + i * 4);
|
|
284
|
+
buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round((right[i] + d) * 32767))), 46 + i * 4);
|
|
285
|
+
}
|
|
286
|
+
fs.writeFileSync(file, buf);
|
|
287
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A brand's shader set: entries in brand/shader-set/set.json, one format for all.
|
|
2
|
+
// build → one scene per entry (shots/) and review boards (boards/), from the template's shot.js.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
|
|
9
|
+
export function readSet(dir) {
|
|
10
|
+
const file = path.join(dir, 'set.json');
|
|
11
|
+
if (!fs.existsSync(file)) throw new Error(`No ${file}`);
|
|
12
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
13
|
+
// older sets were a plain list of { id, video }
|
|
14
|
+
const set = Array.isArray(raw) ? { format: { width: 1920, height: 1080, duration: 6, fps: 30 }, entries: raw } : raw;
|
|
15
|
+
set.format = { width: 1920, height: 1080, duration: 6, fps: 30, ...(set.format ?? {}) };
|
|
16
|
+
return set;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeSet(dir, set) {
|
|
20
|
+
fs.writeFileSync(path.join(dir, 'set.json'), JSON.stringify(set, null, 1) + '\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const page = (title, call) => `<!doctype html>
|
|
24
|
+
<html>
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8">
|
|
27
|
+
<title>${title}</title>
|
|
28
|
+
<style>#stage { overflow: hidden; }</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<main id="stage"></main>
|
|
32
|
+
<script type="module">
|
|
33
|
+
import { mount } from '../shot.js';
|
|
34
|
+
${call}
|
|
35
|
+
</script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export const shotName = (e, i) => `${String(i + 1).padStart(2, '0')}-${e.id}`;
|
|
41
|
+
|
|
42
|
+
/** Writes shot.js, shots/<nn>-<id>.html and boards/board-<k>.html (12 per board). Entries without a shader or file are skipped. */
|
|
43
|
+
export function buildSet(dir) {
|
|
44
|
+
const set = readSet(dir);
|
|
45
|
+
fs.copyFileSync(path.join(ENGINE, 'templates', 'shader-set', 'shot.js'), path.join(dir, 'shot.js'));
|
|
46
|
+
for (const sub of ['shots', 'boards']) { fs.rmSync(path.join(dir, sub), { recursive: true, force: true }); fs.mkdirSync(path.join(dir, sub), { recursive: true }); }
|
|
47
|
+
// shaders that only filter an image need one; say which entry instead of failing inside a render
|
|
48
|
+
const NEEDS_IMAGE = ['ImageDithering', 'HalftoneDots', 'HalftoneCmyk', 'FlutedGlass', 'LensDistortion', 'LiquidMetal', 'GemSmoke', 'Heatmap'];
|
|
49
|
+
const skipped = [];
|
|
50
|
+
const live = set.entries.map((e, i) => ({ e, i })).filter(({ e, i }) => {
|
|
51
|
+
if (!e.shader && !e.file) { skipped.push(`${i + 1} · ${e.id}: no shader or file`); return false; }
|
|
52
|
+
if (NEEDS_IMAGE.includes(e.shader) && !e.image) { skipped.push(`${i + 1} · ${e.id}: ${e.shader} filters an image; give it "image" (wordmark, wordmark-dark or a path in brand/)`); return false; }
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
const F = JSON.stringify(set.format);
|
|
56
|
+
for (const { e, i } of live) fs.writeFileSync(path.join(dir, 'shots', `${shotName(e, i)}.html`), page(`shader set · ${e.id}`, `await mount([${JSON.stringify(e)}], ${F});`));
|
|
57
|
+
const boards = [];
|
|
58
|
+
for (let k = 0; k * 12 < live.length; k++) {
|
|
59
|
+
const chunk = live.slice(k * 12, k * 12 + 12);
|
|
60
|
+
const f = path.join(dir, 'boards', `board-${k + 1}.html`);
|
|
61
|
+
fs.writeFileSync(f, page(`shader set · board ${k + 1}`, `await mount(${JSON.stringify(chunk.map((c) => c.e))}, ${F}, { board: true, numbers: ${JSON.stringify(chunk.map((c) => c.i + 1))} });`));
|
|
62
|
+
boards.push(f);
|
|
63
|
+
}
|
|
64
|
+
return { set, skipped, shots: live.map(({ e, i }) => ({ entry: e, index: i, file: path.join(dir, 'shots', `${shotName(e, i)}.html`) })), boards };
|
|
65
|
+
}
|
package/lib/wordmark.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Wordmarks: a brand name set in its typeface, rendered to a transparent PNG cropped to the ink
|
|
2
|
+
// (for scenes, logo shaders such as LiquidMetal, and the brand folder).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { launch } from './browser.mjs';
|
|
6
|
+
|
|
7
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} o
|
|
11
|
+
* @param {string} o.text the name, already cased as the brand writes it
|
|
12
|
+
* @param {string} o.family font family name
|
|
13
|
+
* @param {string} [o.css] stylesheet URL that provides the family (e.g. Google Fonts css2)
|
|
14
|
+
* @param {string} [o.file] local font file (woff2/ttf/otf), used instead of css
|
|
15
|
+
* @param {number} [o.weight] font weight (default 400)
|
|
16
|
+
* @param {string} [o.tracking] letter-spacing in em, e.g. "-0.02em"
|
|
17
|
+
* @param {string} [o.color] fill color (default #ffffff)
|
|
18
|
+
* @param {number} [o.size] font size in px (default 400)
|
|
19
|
+
* @param {number} [o.pad] transparent margin around the ink, as a fraction of the size (default 0)
|
|
20
|
+
* @param {string} o.out output .png path
|
|
21
|
+
*/
|
|
22
|
+
export async function renderWordmark({ text, family, css, file, weight = 400, tracking = '0em', color = '#ffffff', size = 400, pad = 0, out }) {
|
|
23
|
+
const face = file
|
|
24
|
+
? `@font-face { font-family: 'WM'; src: url(data:font/${path.extname(file).slice(1)};base64,${fs.readFileSync(file).toString('base64')}); font-weight: ${weight}; }`
|
|
25
|
+
: '';
|
|
26
|
+
const fam = file ? 'WM' : family;
|
|
27
|
+
const html = `<!doctype html><html><head>${css ? `<link rel="stylesheet" href="${esc(css)}">` : ''}<style>${face}
|
|
28
|
+
html,body{margin:0;background:transparent}</style></head><body><span style="font-family:'${esc(fam)}';font-weight:${weight}">${esc(text)}</span></body></html>`;
|
|
29
|
+
const browser = await launch();
|
|
30
|
+
try {
|
|
31
|
+
const page = await browser.newPage();
|
|
32
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
33
|
+
const r = await page.evaluate(async ({ fam, weight, size, text, tracking, color, pad }) => {
|
|
34
|
+
const font = `${weight} ${size}px '${fam}'`;
|
|
35
|
+
await document.fonts.load(font).catch(() => {});
|
|
36
|
+
await document.fonts.ready;
|
|
37
|
+
// fonts.check() is true when no face matches at all, so look for a loaded face of this family instead.
|
|
38
|
+
if (![...document.fonts].some((f) => f.family.replace(/['"]/g, '') === fam && f.status === 'loaded')) return { error: 'font' };
|
|
39
|
+
const em = parseFloat(tracking) || 0;
|
|
40
|
+
// Draw generously, then crop to the exact ink so clear space is a brand rule, not a file accident.
|
|
41
|
+
const big = document.createElement('canvas');
|
|
42
|
+
const g = big.getContext('2d');
|
|
43
|
+
g.font = font; g.letterSpacing = `${em * size}px`;
|
|
44
|
+
const m = g.measureText(text);
|
|
45
|
+
big.width = Math.ceil(m.width + size * 2); big.height = Math.ceil(size * 2.4);
|
|
46
|
+
g.font = font; g.letterSpacing = `${em * size}px`; g.fillStyle = color; g.textBaseline = 'alphabetic';
|
|
47
|
+
g.fillText(text, size, size * 1.6);
|
|
48
|
+
const { data, width: W, height: H } = g.getImageData(0, 0, big.width, big.height);
|
|
49
|
+
let x0 = W, y0 = H, x1 = -1, y1 = -1;
|
|
50
|
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (data[(y * W + x) * 4 + 3] > 0) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
|
51
|
+
if (x1 < 0) return { error: 'empty' };
|
|
52
|
+
const p = Math.round(size * pad);
|
|
53
|
+
const c = document.createElement('canvas');
|
|
54
|
+
c.width = x1 - x0 + 1 + p * 2; c.height = y1 - y0 + 1 + p * 2;
|
|
55
|
+
c.getContext('2d').drawImage(big, x0, y0, x1 - x0 + 1, y1 - y0 + 1, p, p, x1 - x0 + 1, y1 - y0 + 1);
|
|
56
|
+
return { url: c.toDataURL('image/png'), width: c.width, height: c.height };
|
|
57
|
+
}, { fam, weight, size, text: String(text), tracking, color, pad });
|
|
58
|
+
if (r.error === 'font') throw new Error(`wordmark: font "${family}" (${weight}) did not load; check --css / --file`);
|
|
59
|
+
if (r.error) throw new Error('wordmark: nothing was drawn');
|
|
60
|
+
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
61
|
+
fs.writeFileSync(out, Buffer.from(r.url.split(',')[1], 'base64'));
|
|
62
|
+
return { out, width: r.width, height: r.height };
|
|
63
|
+
} finally { await browser.close(); }
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paper-motion",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Frame-exact video renderer for HTML scenes built with Paper Shaders: the engine behind the paper-motion agent skills.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"video",
|
|
7
|
+
"motion-graphics",
|
|
8
|
+
"paper-shaders",
|
|
9
|
+
"shaders",
|
|
10
|
+
"render",
|
|
11
|
+
"ffmpeg",
|
|
12
|
+
"agent-skills",
|
|
13
|
+
"claude-code"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/solanodz/paper-motion#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/solanodz/paper-motion/issues"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Solano de Zuasnabar",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/solanodz/paper-motion.git",
|
|
24
|
+
"directory": "packages/engine"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"pamo": "bin/pamo.mjs",
|
|
29
|
+
"paper-motion": "bin/pamo.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"lib/",
|
|
34
|
+
"runtime/",
|
|
35
|
+
"templates/",
|
|
36
|
+
"examples/",
|
|
37
|
+
"!examples/**/renders/",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.17"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@paper-design/shaders": "0.0.82",
|
|
45
|
+
"@paper-design/shaders-react": "0.0.82",
|
|
46
|
+
"playwright-core": "^1.49.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/runtime/brand.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// paper-motion/brand: the brand folder inside a scene, so a piece takes its look, motion, sound and
|
|
2
|
+
// material from brand/ instead of re-deciding them.
|
|
3
|
+
// import { loadBrand } from 'paper-motion/brand';
|
|
4
|
+
// const B = await loadBrand(); // reads /brand/* (serve from the project root)
|
|
5
|
+
// const s = scene({ ..., background: B.color('ground') });
|
|
6
|
+
// B.style(title, 'display'); B.shader(s, '#bg', 'mesh-ground'); B.cue(s, 'transition', 2.4); B.sound(s);
|
|
7
|
+
import { PACE } from './moves.js';
|
|
8
|
+
|
|
9
|
+
const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text()) : null)).catch(() => null);
|
|
10
|
+
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()]; }));
|
|
11
|
+
|
|
12
|
+
export async function loadBrand(root = '/brand') {
|
|
13
|
+
const [tokens, identityMd, motion, sound, setRaw] = await Promise.all([
|
|
14
|
+
get(`${root}/tokens.json`, true), get(`${root}/IDENTITY.md`), get(`${root}/motion.json`, true), get(`${root}/sound.json`, true), get(`${root}/shader-set/set.json`, true),
|
|
15
|
+
]);
|
|
16
|
+
const identity = sections(identityMd);
|
|
17
|
+
const set = Array.isArray(setRaw) ? { entries: setRaw } : setRaw ?? { entries: [] };
|
|
18
|
+
|
|
19
|
+
// fonts: stylesheets and files, all loaded before the first frame
|
|
20
|
+
const fonts = Object.values(tokens?.fonts ?? {}).filter(Boolean);
|
|
21
|
+
await Promise.all(fonts.filter((f) => f.css).map((f) => new Promise((res) => document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: f.css, onload: res, onerror: res })))));
|
|
22
|
+
for (const f of fonts) if (f.file) document.head.append(Object.assign(document.createElement('style'), { textContent: `@font-face { font-family: '${f.family}'; src: url('${root}/${f.file}'); font-weight: 100 900; }` }));
|
|
23
|
+
await Promise.all(fonts.flatMap((f) => (f.weights ?? [400]).map((w) => document.fonts.load(`${w} 40px '${f.family}'`).catch(() => {}))));
|
|
24
|
+
|
|
25
|
+
const C = tokens?.colors ?? {};
|
|
26
|
+
const T = tokens?.type ?? {};
|
|
27
|
+
const fam = (key) => (tokens?.fonts?.[key]?.family ? `'${tokens.fonts[key].family}', system-ui, sans-serif` : 'system-ui, sans-serif');
|
|
28
|
+
const wm = tokens?.wordmark ?? {};
|
|
29
|
+
const pal = sound?.cues ?? {};
|
|
30
|
+
|
|
31
|
+
const B = {
|
|
32
|
+
tokens, identity, motion, sound, set,
|
|
33
|
+
name: wm.text ?? tokens?.brand ?? 'brand',
|
|
34
|
+
/** A color by role (ground, figure, muted, surface, accent…), with a fallback. */
|
|
35
|
+
color: (role, fallback = '#888888') => C[role]?.hex ?? fallback,
|
|
36
|
+
ramp: tokens?.ramp ?? [C.ground?.hex, C.surface?.hex, C.accent?.hex].filter(Boolean),
|
|
37
|
+
/** CSS font-family for a font key (display, text, mono). */
|
|
38
|
+
font: fam,
|
|
39
|
+
/** The type role's numbers (display, title, body, caption), scaled for a short side of `short` px. */
|
|
40
|
+
type: (role, short = 1080) => { const r = T[role] ?? {}; const k = short / (T.reference ?? 1080); return { family: fam(r.font ?? 'text'), weight: r.weight ?? 400, size: (r.size ?? 32) * k, lineHeight: r.lineHeight ?? 1.2, tracking: r.tracking ?? '0em' }; },
|
|
41
|
+
/** Apply a type role to an element. */
|
|
42
|
+
style: (el, role, { short = 1080, color = 'figure' } = {}) => {
|
|
43
|
+
const t = B.type(role, short);
|
|
44
|
+
Object.assign(el.style, { fontFamily: t.family, fontWeight: t.weight, fontSize: `${t.size}px`, lineHeight: t.lineHeight, letterSpacing: t.tracking, color: B.color(color) });
|
|
45
|
+
return el;
|
|
46
|
+
},
|
|
47
|
+
wordmark: { text: wm.text ?? tokens?.brand, onLight: wm.files?.onLight && `${root}/${wm.files.onLight}`, onDark: wm.files?.onDark && `${root}/${wm.files.onDark}` },
|
|
48
|
+
/** Timing for the brand's pace: { enter, hold, stagger } in seconds. */
|
|
49
|
+
pace: { ...PACE[motion?.pace ?? 'medium'], ...(motion?.timing ?? {}) },
|
|
50
|
+
/** Mount a shader-set entry by id on an element (or selector). Returns the layer. */
|
|
51
|
+
shader: (s, el, id, options = {}) => {
|
|
52
|
+
const e = set.entries.find((x) => x.id === id);
|
|
53
|
+
if (!e?.shader) throw new Error(`paper-motion/brand: no shader entry "${id}" in brand/shader-set/set.json`);
|
|
54
|
+
const props = { ...e.props };
|
|
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
|
+
const layer = s.shader(el, e.shader, props, { ...(e.loop ? { loop: e.loop } : {}), ...options });
|
|
57
|
+
// the entry's periodic motion, as in its rendered loop (static shaders stay alive)
|
|
58
|
+
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)) })); }
|
|
59
|
+
return layer;
|
|
60
|
+
},
|
|
61
|
+
/** Schedule the brand's sound for a gesture (transition, land, text) at time t. */
|
|
62
|
+
cue: (s, gesture, t, extra = {}) => { const c = pal[gesture]; if (c) s.sfx(t, c.type, { ...c, ...extra }); },
|
|
63
|
+
/** The brand's keystroke options for keySounds(): { bank, style, gain }. */
|
|
64
|
+
keys: { ...(sound?.keys?.bank ? { bank: sound.keys.bank } : {}), ...(sound?.keys?.style ? { style: sound.keys.style } : {}), gain: sound?.keys?.gain ?? 0.16 },
|
|
65
|
+
/** Apply the brand's master sound settings (and its bed, if any) to a scene. */
|
|
66
|
+
sound: (s) => { if (pal.bed) s.sfx(0, pal.bed.type, { dur: s.duration, ...pal.bed }); s.sound({ lowpass: sound?.lowpass ?? 2000, gain: 1 }); },
|
|
67
|
+
/** The loudness to render at (LUFS). */
|
|
68
|
+
loudness: sound?.loudness ?? -16,
|
|
69
|
+
};
|
|
70
|
+
return B;
|
|
71
|
+
}
|