paper-motion 0.0.0-stage → 0.1.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 +45 -2
- package/bin/pamo.mjs +535 -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/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/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/keys.mjs +93 -0
- package/lib/modules.mjs +23 -0
- package/lib/react-hooks.mjs +9 -0
- package/lib/render.mjs +193 -0
- package/lib/server.mjs +88 -0
- package/lib/sfx.mjs +285 -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/index.js +606 -0
- package/runtime/jsx-shim.js +3 -0
- package/runtime/moves.js +174 -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 +157 -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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>breath</title>
|
|
6
|
+
<style>
|
|
7
|
+
#stage { color: #f3ece2; }
|
|
8
|
+
.layer { position: absolute; inset: 0; }
|
|
9
|
+
.word {
|
|
10
|
+
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
|
|
11
|
+
text-align: center; font: 400 64px/1 "Iowan Old Style", "Palatino", Georgia, serif;
|
|
12
|
+
letter-spacing: 0.16em; text-indent: 0.16em; font-style: italic;
|
|
13
|
+
color: #e9dccb; mix-blend-mode: difference; /* stays legible over light and dark */
|
|
14
|
+
}
|
|
15
|
+
.foot {
|
|
16
|
+
position: absolute; left: 0; right: 0; bottom: 72px; text-align: center;
|
|
17
|
+
font: 500 13px/1 ui-monospace, Menlo, monospace; letter-spacing: 0.4em; color: #e9dccb; mix-blend-mode: difference;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<main id="stage">
|
|
23
|
+
<div id="field" class="layer"></div>
|
|
24
|
+
<div class="word" id="word">breathe</div>
|
|
25
|
+
<div class="foot" id="foot">IN · OUT</div>
|
|
26
|
+
</main>
|
|
27
|
+
<script type="module">
|
|
28
|
+
import { scene, split, stagger, progress, keys, ease, osc } from 'paper-motion';
|
|
29
|
+
|
|
30
|
+
const s = scene({ width: 1080, height: 1350, fps: 30, duration: 12, background: '#1b1a17' });
|
|
31
|
+
|
|
32
|
+
const field = s.shader('#field', 'MeshGradient', {
|
|
33
|
+
colors: ['#1b1a17', '#3d3328', '#a8927a', '#e9dccb'],
|
|
34
|
+
distortion: 0.9, swirl: 0.12, speed: 0.22,
|
|
35
|
+
grainMixer: 0.18, grainOverlay: 0.22,
|
|
36
|
+
}, { loop: 3 });
|
|
37
|
+
|
|
38
|
+
const chars = split('#word', 'chars');
|
|
39
|
+
const foot = document.querySelector('#foot');
|
|
40
|
+
|
|
41
|
+
s.update((t) => {
|
|
42
|
+
// One slow breath per loop: period = duration keeps the loop seamless.
|
|
43
|
+
field.set({ scale: 1.15 + 0.08 * osc(t, 12, -0.25) });
|
|
44
|
+
|
|
45
|
+
chars.forEach((c, i) => {
|
|
46
|
+
const inP = stagger(t, i, { start: 1.2, each: 0.14, duration: 1.6, easing: ease.outCubic });
|
|
47
|
+
const outP = stagger(t, chars.length - 1 - i, { start: 8.2, each: 0.1, duration: 1.4, easing: ease.inCubic });
|
|
48
|
+
const v = inP * (1 - outP);
|
|
49
|
+
c.style.opacity = v;
|
|
50
|
+
c.style.filter = `blur(${(1 - v) * 10}px)`;
|
|
51
|
+
c.style.transform = `translateY(${(1 - inP) * 14 - outP * 10}px)`;
|
|
52
|
+
});
|
|
53
|
+
foot.style.opacity = 0.8 * keys(t, [[2.5, 0], [4, 1, ease.inOutSine], [8, 1], [9.5, 0, ease.inOutSine]]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await s.start();
|
|
57
|
+
</script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# chrome-endcard
|
|
2
|
+
|
|
3
|
+
8 s 16:9 end card: a liquid-metal (chrome) wordmark is uncovered by a soft diagonal wipe while the metal calms down, then a tagline rises and an install command types in, with low synthesized sound.
|
|
4
|
+
|
|
5
|
+
- 1920×1080 · 30 fps · 8 s
|
|
6
|
+
- Shows: an image shader (LiquidMetal) fed a wordmark drawn on a canvas (`wordmark.js`), a CSS mask wipe, animating a shader param per frame with `layer.set()`, `s.sfx()` cues (whoosh, thud), human typing with `typingTimes()` + `keySounds()` and `s.sound()`.
|
|
7
|
+
- Look: chrome on black, Geist Serif and Geist Mono.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>chrome-endcard</title>
|
|
6
|
+
<style>
|
|
7
|
+
@font-face { font-family: 'GSerif'; src: url(./fonts/GeistSerif-Regular.woff2); }
|
|
8
|
+
@font-face { font-family: 'GMono'; font-weight: 100 900; src: url(./fonts/GeistMono-Variable.woff2) format('woff2-variations'); }
|
|
9
|
+
#stage { background: #000; overflow: hidden; }
|
|
10
|
+
.abs { position: absolute; box-sizing: border-box; white-space: pre; }
|
|
11
|
+
#logo { left: 0; top: 190px; width: 1920px; height: 520px; }
|
|
12
|
+
#tag { left: 0; width: 1920px; top: 668px; text-align: center; font: 400 26px/32px GMono; color: #8a8a8a; }
|
|
13
|
+
#cmdrow { left: 0; width: 1920px; top: 762px; height: 26px; text-align: center; font: 500 21px/26px GMono; color: #e6e6e6; }
|
|
14
|
+
#cur { display: inline-block; width: 11px; height: 24px; background: #e6e6e6; vertical-align: -5px; margin-left: 2px; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<main id="stage">
|
|
19
|
+
<div id="logo" class="abs"></div>
|
|
20
|
+
<div id="tag" class="abs">creative-directed videos for people who use coding agents</div>
|
|
21
|
+
<div id="cmdrow" class="abs"><span id="cmd"></span><span id="cur"></span></div>
|
|
22
|
+
</main>
|
|
23
|
+
<script type="module">
|
|
24
|
+
import { scene, progress, keys, ease } from 'paper-motion';
|
|
25
|
+
import { typeOn, typingTimes, keySounds } from 'paper-motion/moves';
|
|
26
|
+
import { wordmark } from './wordmark.js';
|
|
27
|
+
|
|
28
|
+
const s = scene({ width: 1920, height: 1080, fps: 30, duration: 8, background: '#000' });
|
|
29
|
+
await document.fonts.load('400 26px GMono');
|
|
30
|
+
const logoEl = document.querySelector('#logo');
|
|
31
|
+
|
|
32
|
+
// Look B · chrome, approved from the looks board.
|
|
33
|
+
const CHROME = { colorBack: '#000000', colorTint: '#ffffff', softness: 0.12, repetition: 5, shiftRed: 0.3, shiftBlue: 0.3, distortion: 0.14, contour: 0.6, angle: 20 };
|
|
34
|
+
const metal = s.shader(logoEl, 'LiquidMetal', { image: await wordmark(), fit: 'contain', scale: 0.8, speed: 1, shape: 'none', ...CHROME });
|
|
35
|
+
|
|
36
|
+
const tag = document.querySelector('#tag');
|
|
37
|
+
const cmd = document.querySelector('#cmd'), cur = document.querySelector('#cur');
|
|
38
|
+
const CMD = '$ npx skills add solanodz/paper-motion';
|
|
39
|
+
// A person types the install line: bursts inside words, a beat between them; text and keys share the times.
|
|
40
|
+
const TIMES = typingTimes(CMD, { start: 3.2, cps: 14, seed: 4 });
|
|
41
|
+
const ENTER = TIMES[TIMES.length - 1] + 0.4;
|
|
42
|
+
|
|
43
|
+
s.update((t) => {
|
|
44
|
+
// Entrance: a soft diagonal wipe uncovers the word while it settles from 104% and the metal calms down.
|
|
45
|
+
const w = progress(t, 0.3, 2.0, ease.outCubic) * 150 - 30;
|
|
46
|
+
const m = `linear-gradient(100deg, #000 ${w}%, transparent ${w + 30}%)`;
|
|
47
|
+
logoEl.style.maskImage = m; logoEl.style.webkitMaskImage = m;
|
|
48
|
+
logoEl.style.transform = `scale(${1.04 - 0.04 * progress(t, 0.35, 2.6, ease.outExpo)})`;
|
|
49
|
+
metal.set({ distortion: keys(t, [[0, 0.45], [2.6, 0.14, ease.outCubic]]) });
|
|
50
|
+
|
|
51
|
+
// Tagline rises in; the install line types, then the cursor blinks.
|
|
52
|
+
const a = progress(t, 2.3, 3.0, ease.outCubic);
|
|
53
|
+
tag.style.opacity = a; tag.style.transform = `translateY(${(1 - a) * 14}px)`;
|
|
54
|
+
typeOn(cmd, CMD, t, { times: TIMES, cursor: cur });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Sound: one low swell under the reveal, then soft keys. Nothing bright.
|
|
58
|
+
let seed = 7;
|
|
59
|
+
s.sfx(0.3, 'whoosh', { dur: 1.5, from: 260, to: 900, peak: 0.55, gain: 0.32, body: 0.5, seed: seed++ });
|
|
60
|
+
s.sfx(0.55, 'thud', { freq: 48, gain: 0.3, decay: 0.7 });
|
|
61
|
+
s.sfx(2.3, 'paper', { dur: 0.6, gain: 0.04 });
|
|
62
|
+
keySounds(s, CMD, TIMES, { seed: 3, enter: ENTER }); // synthesized; pass { bank } to use real keystrokes (pamo keys)
|
|
63
|
+
s.sound({ lowpass: 2400, gain: 1 });
|
|
64
|
+
|
|
65
|
+
await s.start();
|
|
66
|
+
</script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Draws the "paper-motion" wordmark (Geist Serif) on a transparent canvas and
|
|
2
|
+
// returns it as a PNG data URL. LiquidMetal uses the alpha as the logo shape.
|
|
3
|
+
export async function wordmark({ text = 'paper-motion', size = 420, pad = 60 } = {}) {
|
|
4
|
+
await document.fonts.load(`400 ${size}px GSerif`);
|
|
5
|
+
const c = document.createElement('canvas');
|
|
6
|
+
const g = c.getContext('2d');
|
|
7
|
+
g.font = `400 ${size}px GSerif`;
|
|
8
|
+
const m = g.measureText(text);
|
|
9
|
+
c.width = Math.ceil(m.width + pad * 2);
|
|
10
|
+
c.height = Math.ceil(m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + pad * 2);
|
|
11
|
+
g.font = `400 ${size}px GSerif`;
|
|
12
|
+
g.letterSpacing = '-0.01em';
|
|
13
|
+
g.fillStyle = '#fff';
|
|
14
|
+
g.fillText(text, pad, pad + m.actualBoundingBoxAscent);
|
|
15
|
+
return c.toDataURL('image/png');
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# install-loop
|
|
2
|
+
|
|
3
|
+
Black 16:9 loop for a README: a gray dithered sphere breathes behind a serif wordmark while an install command types itself and erases, so the loop closes on an empty prompt.
|
|
4
|
+
|
|
5
|
+
- 1600×900 · 30 fps · 6 s · seamless (the shader uses `{ loop: 1.2 }`)
|
|
6
|
+
- Shows: Dithering with a seamless loop, terminal typing with a block cursor driven by `progress()`.
|
|
7
|
+
- Look: Geist Serif wordmark, Geist Mono tagline and command, `#A3A3A3` on black.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>install-loop</title>
|
|
6
|
+
<style>
|
|
7
|
+
@font-face { font-family: 'GSerif'; src: url(./fonts/GeistSerif-Regular.woff2); }
|
|
8
|
+
@font-face { font-family: 'GMono'; font-weight: 100 900; src: url(./fonts/GeistMono-Variable.woff2) format('woff2-variations'); }
|
|
9
|
+
#stage { background: #000; overflow: hidden; }
|
|
10
|
+
.abs { position: absolute; white-space: pre; box-sizing: border-box; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main id="stage">
|
|
15
|
+
<!-- layout and shader values were read from a Paper canvas with get_jsx -->
|
|
16
|
+
<div id="bg" class="abs" style="left:0;top:0;width:1600px;height:900px;background:#000"></div>
|
|
17
|
+
<div id="name" class="abs" style="left:92px;top:520px;font-family:GSerif;font-size:200px;line-height:200px;letter-spacing:-0.02em;color:#fff">paper-motion</div>
|
|
18
|
+
<div id="tag" class="abs" style="left:100px;top:790px;font-family:GMono;font-weight:400;font-size:26px;line-height:32px;color:#A3A3A3">creative-directed videos for people who use coding agents</div>
|
|
19
|
+
<div id="cmd" class="abs" style="left:100px;top:72px;font-family:GMono;font-weight:500;font-size:20px;line-height:24px;color:#fff"></div>
|
|
20
|
+
<div id="cur" class="abs" style="top:72px;width:11px;height:24px;background:#fff"></div>
|
|
21
|
+
</main>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { scene, progress } from 'paper-motion';
|
|
24
|
+
|
|
25
|
+
const s = scene({ width: 1600, height: 900, fps: 30, duration: 6, background: '#000' });
|
|
26
|
+
await Promise.all(['400 100px GSerif', '500 20px GMono'].map((f) => document.fonts.load(f)));
|
|
27
|
+
|
|
28
|
+
// The user's shader from Paper, with their note applied ("the sphere in gray").
|
|
29
|
+
s.shader('#bg', 'Dithering', { speed: 1, shape: 'sphere', type: '4x4', size: 2, scale: 0.6, colorBack: '#00000000', colorFront: '#A3A3A3' }, { loop: 1.2 });
|
|
30
|
+
|
|
31
|
+
// The install line types in, holds, then erases, so the loop closes on an empty prompt.
|
|
32
|
+
const CMD = '$ npx skills add solanodz/paper-motion', CW = 12.05;
|
|
33
|
+
const cmd = document.querySelector('#cmd'), cur = document.querySelector('#cur');
|
|
34
|
+
s.update((t) => {
|
|
35
|
+
const typed = Math.floor(CMD.length * progress(t, 0.4, 1.7));
|
|
36
|
+
const erased = Math.floor(CMD.length * progress(t, 5.0, 5.55));
|
|
37
|
+
const n = Math.max(0, typed - erased);
|
|
38
|
+
cmd.textContent = CMD.slice(0, n);
|
|
39
|
+
cur.style.left = `${100 + n * CW + 2}px`;
|
|
40
|
+
const typing = (t > 0.4 && t < 1.7) || (t > 5.0 && t < 5.55);
|
|
41
|
+
cur.style.opacity = typing || Math.floor(t * 2) % 2 === 0 ? 1 : 0;
|
|
42
|
+
});
|
|
43
|
+
await s.start();
|
|
44
|
+
</script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Brief
|
|
2
|
+
|
|
3
|
+
Monochrome 16:9 study: a dithered sphere approaching under an instrument-like HUD.
|
|
4
|
+
|
|
5
|
+
**Intent** — the cold calm of a measurement.
|
|
6
|
+
**Format** — 1920×1080 · 30fps · 10s · no loop · screen / site hero.
|
|
7
|
+
**Palette** — carbon `#0a0a0a`, paper `#e8e6df`. Nothing else.
|
|
8
|
+
**Material** — Dithering (sphere, 4×4 Bayer, 3px cells): 1-bit texture, no gradients.
|
|
9
|
+
**Type** — mono, small, tracked, pinned to the four corners; values are driven by the frame counter.
|
|
10
|
+
**Motion** — a single scale gesture (in, hold, ease back). The rule fills with the clip.
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>signal</title>
|
|
6
|
+
<style>
|
|
7
|
+
#stage { color: #e8e6df; font: 500 14px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.18em; }
|
|
8
|
+
.layer { position: absolute; inset: 0; }
|
|
9
|
+
.tl, .tr, .bl, .br { position: absolute; }
|
|
10
|
+
.tl { left: 64px; top: 60px; } .tr { right: 64px; top: 60px; text-align: right; }
|
|
11
|
+
.bl { left: 64px; bottom: 60px; } .br { right: 64px; bottom: 60px; }
|
|
12
|
+
.rule { position: absolute; left: 64px; right: 64px; bottom: 96px; height: 1px; background: #e8e6df; transform-origin: 0 0; opacity: .5; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main id="stage">
|
|
17
|
+
<div id="field" class="layer"></div>
|
|
18
|
+
<div class="tl">SIGNAL / 001</div>
|
|
19
|
+
<div class="tr" id="tc">00:00:00</div>
|
|
20
|
+
<div class="rule" id="rule"></div>
|
|
21
|
+
<div class="bl">A SPHERE, SAMPLED IN 4×4</div>
|
|
22
|
+
<div class="br" id="pct">000</div>
|
|
23
|
+
</main>
|
|
24
|
+
<script type="module">
|
|
25
|
+
import { scene, keys, progress, ease } from 'paper-motion';
|
|
26
|
+
|
|
27
|
+
const s = scene({ width: 1920, height: 1080, fps: 30, duration: 10, background: '#0a0a0a' });
|
|
28
|
+
|
|
29
|
+
const field = s.shader('#field', 'Dithering', {
|
|
30
|
+
colorBack: '#0a0a0a', colorFront: '#e8e6df',
|
|
31
|
+
shape: 'sphere', type: '4x4', size: 3, speed: 0.5,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const tc = document.querySelector('#tc'), pct = document.querySelector('#pct'), rule = document.querySelector('#rule');
|
|
35
|
+
|
|
36
|
+
s.update((t, { frame, fps }) => {
|
|
37
|
+
// The sphere approaches, holds, then recedes: one gesture, no tricks.
|
|
38
|
+
field.set({ scale: keys(t, [[0, 0.3], [6, 0.72, ease.inOutCubic], [10, 0.62, ease.inOutSine]]) });
|
|
39
|
+
const ff = String(frame % fps).padStart(2, '0');
|
|
40
|
+
const ss = String(Math.floor(t)).padStart(2, '0');
|
|
41
|
+
tc.textContent = `00:${ss}:${ff}`;
|
|
42
|
+
const p = progress(t, 0, 10);
|
|
43
|
+
pct.textContent = String(Math.round(p * 100)).padStart(3, '0');
|
|
44
|
+
rule.style.transform = `scaleX(${progress(t, 0.4, 9.6, ease.inOutQuart)})`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await s.start();
|
|
48
|
+
</script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
package/lib/board.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Option boards: N rendered options (stills or short clips) composed into one labelled grid,
|
|
2
|
+
// so an interview can show choices side by side and the user answers with a letter.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { launch } from './browser.mjs';
|
|
7
|
+
|
|
8
|
+
const BOARD_W = 1920, GAP = 16, PAD = 40, LABEL_H = 64;
|
|
9
|
+
|
|
10
|
+
/** Grid for n options: 2–3 in a row, 4 as 2×2, 5–6 as 3×2 (never an empty cell for 3); `cols` overrides. */
|
|
11
|
+
export function layout(n, aspect, colsOverride) {
|
|
12
|
+
const cols = colsOverride ? Math.max(1, Math.min(n, colsOverride)) : n <= 3 ? n : n === 4 ? 2 : 3;
|
|
13
|
+
const rows = Math.ceil(n / cols);
|
|
14
|
+
const cw = Math.floor((BOARD_W - PAD * 2 - GAP * (cols - 1)) / cols / 2) * 2;
|
|
15
|
+
const ch = Math.floor(cw / aspect / 2) * 2;
|
|
16
|
+
const cells = Array.from({ length: n }, (_, i) => ({
|
|
17
|
+
x: PAD + (i % cols) * (cw + GAP),
|
|
18
|
+
y: PAD + Math.floor(i / cols) * (ch + LABEL_H + GAP) + LABEL_H,
|
|
19
|
+
}));
|
|
20
|
+
const H = Math.ceil((PAD * 2 + rows * (ch + LABEL_H) + (rows - 1) * GAP) / 2) * 2;
|
|
21
|
+
return { cols, rows, cw, ch, W: BOARD_W, H, cells };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Transparent PNG with each option's letter and caption above its cell. */
|
|
25
|
+
async function labelsPng(L, labels, file, { backing = false } = {}) {
|
|
26
|
+
const browser = await launch();
|
|
27
|
+
try {
|
|
28
|
+
const page = await browser.newPage({ viewport: { width: L.W, height: L.H } });
|
|
29
|
+
const items = L.cells.map((c, i) => {
|
|
30
|
+
const [letter, ...rest] = labels[i].split(' · ');
|
|
31
|
+
return `<div style="position:absolute;left:${c.x}px;top:${c.y - LABEL_H}px;height:${LABEL_H}px;width:${L.cw}px;overflow:hidden;display:flex;align-items:center;gap:14px;${backing ? 'width:auto;max-width:' + L.cw + 'px;padding:0 18px 0 12px;border-radius:10px;background:rgba(0,0,0,.62);' : ''}font:500 26px ui-monospace,Menlo,monospace;color:#fff">
|
|
32
|
+
<span style="flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border:1.5px solid #fff;border-radius:6px">${letter}</span>
|
|
33
|
+
<span style="color:#9a9a9a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${rest.join(' · ')}</span></div>`;
|
|
34
|
+
}).join('');
|
|
35
|
+
await page.setContent(`<html><body style="margin:0;background:transparent">${items}</body></html>`);
|
|
36
|
+
await page.screenshot({ path: file, omitBackground: true });
|
|
37
|
+
} finally { await browser.close(); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ffmpeg(args) {
|
|
41
|
+
return new Promise((res, rej) => {
|
|
42
|
+
const p = spawn('ffmpeg', ['-y', '-loglevel', 'error', ...args], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
43
|
+
p.on('close', (code) => (code === 0 ? res() : rej(new Error(`ffmpeg exited ${code}`))));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compose rendered option files (all PNG or all MP4) into one board.
|
|
49
|
+
* @param {string[]} inputs rendered options, in label order
|
|
50
|
+
* @param {string[]} labels "A · caption" per option
|
|
51
|
+
* @param {number} aspect cell aspect ratio (width / height)
|
|
52
|
+
* @param {string} out .png for stills, .mp4 for clips
|
|
53
|
+
* @param {number} duration clip length in seconds (bounds the looped label layer)
|
|
54
|
+
*/
|
|
55
|
+
export async function composeBoard({ inputs, labels, aspect, out, tmp, duration, cols }) {
|
|
56
|
+
const L = layout(inputs.length, aspect, cols);
|
|
57
|
+
const lab = path.join(tmp, 'labels.png');
|
|
58
|
+
await labelsPng(L, labels, lab);
|
|
59
|
+
const clip = out.endsWith('.mp4');
|
|
60
|
+
const ins = inputs.flatMap((f) => ['-i', f]);
|
|
61
|
+
const scaled = inputs.map((_, i) => `[${i}:v]scale=${L.cw}:${L.ch}:force_original_aspect_ratio=decrease,pad=${L.cw}:${L.ch}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[v${i}]`);
|
|
62
|
+
let chain = `color=c=0x0a0a0a:s=${L.W}x${L.H}${clip ? ':r=30' : ''}[bg0]`;
|
|
63
|
+
inputs.forEach((_, i) => { chain += `;[bg${i}][v${i}]overlay=${L.cells[i].x}:${L.cells[i].y}${clip ? ':shortest=1' : ''}[bg${i + 1}]`; });
|
|
64
|
+
chain += `;[bg${inputs.length}][${inputs.length}:v]overlay=0:0${clip ? ':format=auto' : ''}[out]`;
|
|
65
|
+
const filter = [...scaled, chain].join(';');
|
|
66
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
67
|
+
if (clip) await ffmpeg([...ins, '-loop', '1', '-i', lab, '-filter_complex', filter, '-map', '[out]', ...(duration ? ['-t', String(duration)] : []), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-an', out]);
|
|
68
|
+
else await ffmpeg([...ins, '-i', lab, '-filter_complex', filter, '-map', '[out]', '-frames:v', '1', out]);
|
|
69
|
+
return { out, ...L };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clips one after another, each with its letter and caption burned in and its own sound,
|
|
74
|
+
* so sound choices are heard in turn: A, then B, then C.
|
|
75
|
+
*/
|
|
76
|
+
export async function composeSequence({ inputs, labels, out, tmp, duration }) {
|
|
77
|
+
const segs = [];
|
|
78
|
+
for (const [i, f] of inputs.entries()) {
|
|
79
|
+
const probe = await new Promise((res) => { const p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', f]); let o = ''; p.stdout.on('data', (d) => (o += d)); p.on('close', () => res(o.trim())); });
|
|
80
|
+
const [w, h] = probe.split(',').map(Number);
|
|
81
|
+
const L = { W: w, H: h, cw: w - 80, cells: [{ x: 40, y: 40 + LABEL_H }] };
|
|
82
|
+
const lab = path.join(tmp, `seq-label-${i}.png`);
|
|
83
|
+
await labelsPng(L, [labels[i]], lab, { backing: true });
|
|
84
|
+
const seg = path.join(tmp, `seq-${i}.mp4`);
|
|
85
|
+
const hasAudio = await new Promise((res) => { const p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', f]); let o = ''; p.stdout.on('data', (d) => (o += d)); p.on('close', () => res(!!o.trim())); });
|
|
86
|
+
// every segment carries a stereo track (silence when the option has no sound) so they concatenate cleanly
|
|
87
|
+
await ffmpeg(['-i', f, '-loop', '1', '-i', lab, '-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=stereo', '-filter_complex', '[0:v][1:v]overlay=0:0:format=auto[v]', '-map', '[v]', '-map', hasAudio ? '0:a' : '2:a', ...(hasAudio ? ['-af', 'loudnorm=I=-20:TP=-2:LRA=11'] : []), '-t', String(duration), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', seg]);
|
|
88
|
+
segs.push(seg);
|
|
89
|
+
}
|
|
90
|
+
const list = path.join(tmp, 'seq.txt');
|
|
91
|
+
fs.writeFileSync(list, segs.map((s) => `file '${s}'`).join('\n'));
|
|
92
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
93
|
+
await ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', out]);
|
|
94
|
+
return { out, W: 0, H: 0 };
|
|
95
|
+
}
|
package/lib/browser.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { chromium } from 'playwright-core';
|
|
4
|
+
|
|
5
|
+
const CANDIDATES = {
|
|
6
|
+
darwin: [
|
|
7
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
8
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
9
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
10
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
11
|
+
],
|
|
12
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
|
|
13
|
+
win32: [
|
|
14
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
15
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
16
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function findBrowser() {
|
|
21
|
+
const env = process.env.PAPER_MOTION_BROWSER;
|
|
22
|
+
if (env && fs.existsSync(env)) return env;
|
|
23
|
+
try { const p = chromium.executablePath(); if (p && fs.existsSync(p)) return p; } catch { /* not installed */ }
|
|
24
|
+
for (const p of CANDIDATES[os.platform()] ?? []) if (fs.existsSync(p)) return p;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function launch() {
|
|
29
|
+
const executablePath = findBrowser();
|
|
30
|
+
if (!executablePath) {
|
|
31
|
+
throw new Error('No Chromium-based browser found. Install Google Chrome, or run:\n npx playwright-core install chromium\nor set PAPER_MOTION_BROWSER=/path/to/chrome');
|
|
32
|
+
}
|
|
33
|
+
return chromium.launch({
|
|
34
|
+
executablePath,
|
|
35
|
+
headless: true,
|
|
36
|
+
args: [
|
|
37
|
+
'--ignore-gpu-blocklist',
|
|
38
|
+
'--enable-gpu-rasterization',
|
|
39
|
+
'--enable-unsafe-swiftshader', // WebGL fallback on machines without a GPU (CI, servers)
|
|
40
|
+
'--hide-scrollbars',
|
|
41
|
+
'--mute-audio',
|
|
42
|
+
'--force-color-profile=srgb',
|
|
43
|
+
'--disable-background-timer-throttling',
|
|
44
|
+
'--disable-renderer-backgrounding',
|
|
45
|
+
'--disable-backgrounding-occluded-windows',
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Open a scene in render mode and wait until it is ready. */
|
|
51
|
+
export async function openScene(browser, url, { dpr = 1 } = {}) {
|
|
52
|
+
// Probe size with a throwaway page first, so the real viewport matches the scene.
|
|
53
|
+
const probe = await browser.newPage();
|
|
54
|
+
const errors = [];
|
|
55
|
+
probe.on('pageerror', (e) => errors.push(e));
|
|
56
|
+
probe.on('console', (m) => { if (m.type() === 'error' && !m.text().startsWith('Failed to load resource')) errors.push(new Error(m.text())); });
|
|
57
|
+
// A missing script, style, font or image breaks a scene; a fetch() may probe optional files (brand data) and handles its own 404.
|
|
58
|
+
probe.on('response', (r) => { const k = r.request().resourceType(); if (r.status() >= 400 && k !== 'fetch' && k !== 'xhr') errors.push(new Error(`${r.status()} ${r.url()}`)); });
|
|
59
|
+
await probe.goto(url);
|
|
60
|
+
const config = await waitReady(probe, errors);
|
|
61
|
+
await probe.close();
|
|
62
|
+
|
|
63
|
+
const context = await browser.newContext({ viewport: { width: config.width, height: config.height }, deviceScaleFactor: dpr });
|
|
64
|
+
const page = await context.newPage();
|
|
65
|
+
const errs = [];
|
|
66
|
+
page.on('pageerror', (e) => errs.push(e));
|
|
67
|
+
page.on('console', (m) => { if ((m.type() === 'error' || m.type() === 'warning') && !m.text().startsWith('Failed to load resource')) process.stderr.write(`[scene ${m.type()}] ${m.text()}\n`); });
|
|
68
|
+
await page.goto(url);
|
|
69
|
+
await waitReady(page, errs);
|
|
70
|
+
const cdp = await context.newCDPSession(page);
|
|
71
|
+
const capture = async () => {
|
|
72
|
+
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true, captureBeyondViewport: false, fromSurface: true });
|
|
73
|
+
return Buffer.from(data, 'base64');
|
|
74
|
+
};
|
|
75
|
+
const seek = (t) => page.evaluate((tt) => window.__pm.seek(tt), t);
|
|
76
|
+
return { page, context, config, capture, seek, close: () => context.close() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function waitReady(page, errors, timeout = 60000) {
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
while (Date.now() - start < timeout) {
|
|
82
|
+
if (errors.length) throw new Error(`Scene error: ${errors[0].message}`);
|
|
83
|
+
const cfg = await page.evaluate(() => (window.__pm ? window.__pm.config : null)).catch(() => null);
|
|
84
|
+
if (cfg) return cfg;
|
|
85
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
86
|
+
}
|
|
87
|
+
throw new Error('Scene never became ready. Did you call `await s.start()`? Check the browser console in preview.');
|
|
88
|
+
}
|
package/lib/catalog.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Builds a catalog of every Paper shader: description, params with defaults, presets.
|
|
2
|
+
import { register } from 'node:module';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { moduleDir } from './modules.mjs';
|
|
7
|
+
|
|
8
|
+
register('./react-hooks.mjs', import.meta.url);
|
|
9
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const SIZING = ['fit', 'scale', 'rotation', 'originX', 'originY', 'offsetX', 'offsetY', 'worldWidth', 'worldHeight'];
|
|
11
|
+
|
|
12
|
+
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
13
|
+
|
|
14
|
+
function describe(name) {
|
|
15
|
+
const f = path.join(moduleDir('@paper-design/shaders') ?? path.join(ENGINE, 'node_modules/@paper-design/shaders'), 'dist/shaders', `${kebab(name)}.d.ts`);
|
|
16
|
+
if (!fs.existsSync(f)) return '';
|
|
17
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
18
|
+
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
|
19
|
+
if (!m) return '';
|
|
20
|
+
const lines = m[1].split('\n').map((l) => l.replace(/^\s*\*\s?/, '').trim());
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const l of lines) { if (/uniforms:|^Fragment shader|^Vertex shader/i.test(l)) break; if (l) out.push(l); }
|
|
23
|
+
return out.join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function enums(name, mod) {
|
|
27
|
+
// Expose string options for enum-like params (shape, type, ...)
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [k, v] of Object.entries(mod)) {
|
|
30
|
+
if (k.startsWith(name.replace(/^Static/, '')) && /(Shapes|Types|Patterns|Grids|AspectRatios)$/.test(k) && v && typeof v === 'object') out[k] = Object.keys(v);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function buildCatalog() {
|
|
36
|
+
const R = await import('@paper-design/shaders-react');
|
|
37
|
+
const V = await import('@paper-design/shaders');
|
|
38
|
+
const names = Object.keys(R).filter((k) => /^[A-Z]/.test(k) && k !== 'ShaderMount' && typeof R[k] === 'function');
|
|
39
|
+
return names.map((name) => {
|
|
40
|
+
const presets = R[`${name[0].toLowerCase()}${name.slice(1)}Presets`] ?? [];
|
|
41
|
+
const def = presets[0]?.params ?? {};
|
|
42
|
+
const own = Object.fromEntries(Object.entries(def).filter(([k]) => !SIZING.includes(k)));
|
|
43
|
+
const GENERIC = { fit: ['none', 'contain'], scale: [1], rotation: [0], offsetX: [0], offsetY: [0], originX: [0.5], originY: [0.5], worldWidth: [0], worldHeight: [0] };
|
|
44
|
+
const sizing = Object.fromEntries(Object.entries(def).filter(([k, v]) => SIZING.includes(k) && !GENERIC[k].includes(v)));
|
|
45
|
+
const isImage = 'image' in own || /\bimage\s*=/.test(R[name].toString());
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
description: describe(name),
|
|
49
|
+
animated: (def.speed ?? 0) !== 0,
|
|
50
|
+
usesImage: isImage,
|
|
51
|
+
params: own,
|
|
52
|
+
sizing, // non-obvious sizing defaults, e.g. GodRays sits high (offsetY -0.55)
|
|
53
|
+
options: enums(name, V),
|
|
54
|
+
presets: presets.map((p) => ({ name: p.name, params: Object.fromEntries(Object.entries(p.params).filter(([k]) => !SIZING.includes(k) || p.params[k] !== def[k])) })),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function catalogMarkdown(cat, version) {
|
|
60
|
+
const lines = [`# Paper Shaders catalog`, '', `Generated from @paper-design/shaders-react ${version}. Regenerate with \`paper-motion shaders --md\`.`, '',
|
|
61
|
+
'Every shader also accepts the sizing params: `fit` (none|contain|cover), `scale`, `rotation` (deg), `originX`, `originY`, `offsetX`, `offsetY`, `worldWidth`, `worldHeight`.',
|
|
62
|
+
'Motion params: `speed` (multiplier on scene time; 0 = still) and `frame` (start offset, ms).', '',
|
|
63
|
+
'| Shader | Animated | Image input | What it is |', '|---|---|---|---|',
|
|
64
|
+
...cat.map((s) => `| [${s.name}](#${s.name.toLowerCase()}) | ${s.animated ? 'yes' : '—'} | ${s.usesImage ? 'yes' : '—'} | ${s.description.split('. ')[0].replace(/\|/g, '/')} |`), ''];
|
|
65
|
+
for (const s of cat) {
|
|
66
|
+
lines.push(`## ${s.name}`, '', s.description, '');
|
|
67
|
+
lines.push('Defaults:', '```js', JSON.stringify(s.params, null, 0).replace(/,"/g, ', "'), '```');
|
|
68
|
+
if (Object.keys(s.sizing).length) lines.push(`Sizing defaults that differ from neutral: \`${JSON.stringify(s.sizing)}\` — pass them explicitly (e.g. \`offsetY: 0\`) to re-center.`);
|
|
69
|
+
for (const [k, v] of Object.entries(s.options)) lines.push(`- \`${k}\`: ${v.map((x) => `\`${x}\``).join(', ')}`);
|
|
70
|
+
if (s.presets.length > 1) {
|
|
71
|
+
lines.push('', 'Presets:');
|
|
72
|
+
for (const p of s.presets.slice(1)) lines.push(`- **${p.name}**: \`${JSON.stringify(p.params).replace(/,"/g, ', "')}\``);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
package/lib/keys.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Keystroke banks: split a recording of real typing into single hits, so the `key` sound effect can play
|
|
2
|
+
// real keystrokes instead of synthesized ones. Any audio ffmpeg reads (mp3, m4a, wav, mp4...).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const SR = 48000;
|
|
8
|
+
|
|
9
|
+
/** Decode any audio file to mono Float32 at 48 kHz. */
|
|
10
|
+
export function decode(file) {
|
|
11
|
+
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', file, '-ac', '1', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
|
|
12
|
+
if (r.status !== 0) throw new Error(`could not decode ${file}: ${r.stderr}`);
|
|
13
|
+
return new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.byteLength / 4).slice();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 16-bit mono WAV. */
|
|
17
|
+
export function writeMonoWav(file, data) {
|
|
18
|
+
const buf = Buffer.alloc(44 + data.length * 2);
|
|
19
|
+
buf.write('RIFF', 0); buf.writeUInt32LE(36 + data.length * 2, 4); buf.write('WAVE', 8); buf.write('fmt ', 12);
|
|
20
|
+
buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(SR, 24);
|
|
21
|
+
buf.writeUInt32LE(SR * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(data.length * 2, 40);
|
|
22
|
+
for (let i = 0; i < data.length; i++) buf.writeInt16LE(Math.round(Math.max(-1, Math.min(1, data[i])) * 32767), 44 + i * 2);
|
|
23
|
+
fs.writeFileSync(file, buf);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find keystroke onsets with a fast/slow energy ratio, then cut each hit until just before the next.
|
|
28
|
+
* @returns {{ hits: {file, t, dur, peak, low}[] }}
|
|
29
|
+
*/
|
|
30
|
+
export function extractKeys(input, outDir, { minGap = 0.055, maxLen = 0.28, sensitivity = 1 } = {}) {
|
|
31
|
+
const x = decode(input);
|
|
32
|
+
// envelope: fast (1.5 ms) vs slow (40 ms) RMS; an onset is where the fast one jumps above the slow one
|
|
33
|
+
const fast = new Float32Array(x.length), slow = new Float32Array(x.length);
|
|
34
|
+
const af = Math.exp(-1 / (0.0015 * SR)), as = Math.exp(-1 / (0.04 * SR));
|
|
35
|
+
let f = 0, s = 0, peakAll = 1e-9;
|
|
36
|
+
for (let i = 0; i < x.length; i++) {
|
|
37
|
+
const e = x[i] * x[i];
|
|
38
|
+
f = af * f + (1 - af) * e; s = as * s + (1 - as) * e;
|
|
39
|
+
fast[i] = Math.sqrt(f); slow[i] = Math.sqrt(s);
|
|
40
|
+
peakAll = Math.max(peakAll, fast[i]);
|
|
41
|
+
}
|
|
42
|
+
const floor = peakAll * 0.04 / sensitivity, gap = Math.round(minGap * SR);
|
|
43
|
+
const onsets = [];
|
|
44
|
+
for (let i = 1; i < x.length; i++) {
|
|
45
|
+
if (fast[i] > floor && fast[i] > slow[i] * (2.2 / sensitivity) && (!onsets.length || i - onsets[onsets.length - 1] > gap)) onsets.push(i);
|
|
46
|
+
}
|
|
47
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
48
|
+
const hits = [];
|
|
49
|
+
onsets.forEach((o, k) => {
|
|
50
|
+
const a = Math.max(0, o - Math.round(0.004 * SR));
|
|
51
|
+
const end = Math.min(x.length, k + 1 < onsets.length ? onsets[k + 1] - Math.round(0.006 * SR) : x.length, a + Math.round(maxLen * SR));
|
|
52
|
+
if (end - a < 0.03 * SR) return;
|
|
53
|
+
const seg = x.slice(a, end);
|
|
54
|
+
// fade in 2 ms, fade out the last 25 %, normalize to 0.9
|
|
55
|
+
let pk = 1e-9; for (const v of seg) pk = Math.max(pk, Math.abs(v));
|
|
56
|
+
const fi = Math.round(0.002 * SR), fo = Math.round(seg.length * 0.25);
|
|
57
|
+
for (let i = 0; i < seg.length; i++) {
|
|
58
|
+
const g = Math.min(1, i / fi) * Math.min(1, (seg.length - i) / fo);
|
|
59
|
+
seg[i] = (seg[i] / pk) * 0.9 * g;
|
|
60
|
+
}
|
|
61
|
+
// a rough "weight": share of energy under 400 Hz (spacebars and enters are heavier)
|
|
62
|
+
let lo = 0, all = 0, y = 0; const c = Math.exp(-2 * Math.PI * 400 / SR);
|
|
63
|
+
for (const v of seg) { y = (1 - c) * v + c * y; lo += y * y; all += v * v; }
|
|
64
|
+
const name = `key-${String(hits.length + 1).padStart(3, '0')}.wav`;
|
|
65
|
+
writeMonoWav(path.join(outDir, name), seg);
|
|
66
|
+
hits.push({ file: name, t: +(a / SR).toFixed(3), dur: +(seg.length / SR).toFixed(3), peak: +pk.toFixed(3), low: +(lo / Math.max(all, 1e-12)).toFixed(3) });
|
|
67
|
+
});
|
|
68
|
+
// the heaviest fifth become the space/enter pool
|
|
69
|
+
const byLow = [...hits].sort((p, q) => q.low - p.low);
|
|
70
|
+
const heavy = new Set(byLow.slice(0, Math.max(1, Math.round(hits.length / 5))).map((h) => h.file));
|
|
71
|
+
for (const h of hits) h.kind = heavy.has(h.file) ? 'heavy' : 'letter';
|
|
72
|
+
fs.writeFileSync(path.join(outDir, 'keys.json'), JSON.stringify({ source: path.basename(input), sampleRate: SR, hits }, null, 1) + '\n');
|
|
73
|
+
return { hits };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const banks = new Map();
|
|
77
|
+
/** Load a bank folder written by extractKeys: { letter: Float32Array[], heavy: Float32Array[] }. */
|
|
78
|
+
export function loadBank(dir) {
|
|
79
|
+
const abs = path.resolve(dir);
|
|
80
|
+
if (banks.has(abs)) return banks.get(abs);
|
|
81
|
+
const meta = JSON.parse(fs.readFileSync(path.join(abs, 'keys.json'), 'utf8'));
|
|
82
|
+
const bank = { letter: [], heavy: [] };
|
|
83
|
+
for (const h of meta.hits) {
|
|
84
|
+
const b = fs.readFileSync(path.join(abs, h.file));
|
|
85
|
+
const n = (b.length - 44) / 2, d = new Float32Array(n);
|
|
86
|
+
for (let i = 0; i < n; i++) d[i] = b.readInt16LE(44 + i * 2) / 32768;
|
|
87
|
+
(h.kind === 'heavy' ? bank.heavy : bank.letter).push(d);
|
|
88
|
+
}
|
|
89
|
+
if (!bank.letter.length) bank.letter = bank.heavy;
|
|
90
|
+
if (!bank.heavy.length) bank.heavy = bank.letter;
|
|
91
|
+
banks.set(abs, bank);
|
|
92
|
+
return bank;
|
|
93
|
+
}
|