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/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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Customizing a resource: the code for a set of param values. One module for both sides, so what the site shows
|
|
2
|
+
// (its code tabs, "Copy for your agent") and what `pamo add <id> --set k=v` installs are always the same.
|
|
3
|
+
// Plain ESM with no Node imports: the site copies this file and runs it in the browser.
|
|
4
|
+
|
|
5
|
+
export const MOVE_TYPES = ['component', 'transition', 'text', 'camera'];
|
|
6
|
+
|
|
7
|
+
/** Param defaults by name. */
|
|
8
|
+
export const defaults = (item) => Object.fromEntries((item.params ?? []).map((p) => [p.name, p.default]));
|
|
9
|
+
|
|
10
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
11
|
+
/** Params whose value differs from the default. */
|
|
12
|
+
export const changed = (item, values) => (item.params ?? []).filter((p) => p.name in values && !same(values[p.name], p.default));
|
|
13
|
+
|
|
14
|
+
/** A JS literal: strings in single quotes, `ease.*` left as code, arrays and objects inline. */
|
|
15
|
+
export function literal(v) {
|
|
16
|
+
if (typeof v === 'string') return /^ease\.\w+$/.test(v) ? v : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
17
|
+
if (Array.isArray(v)) return `[${v.map(literal).join(', ')}]`;
|
|
18
|
+
if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x)}`).join(', ')} }`;
|
|
19
|
+
return String(v);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `--set key=value` arguments, shell-safe. */
|
|
23
|
+
export function setArgs(item, values) {
|
|
24
|
+
return changed(item, values).map((p) => {
|
|
25
|
+
const v = values[p.name], raw = typeof v === 'string' ? v : JSON.stringify(v);
|
|
26
|
+
const arg = `${p.name}=${raw}`;
|
|
27
|
+
return /^[\w.=#,:/@+-]+$/.test(arg) ? `--set ${arg}` : `--set '${arg.replace(/'/g, "'\\''")}'`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parses `key=value` pairs (from the CLI) into values, JSON where it parses, strings otherwise. */
|
|
32
|
+
export function parseSet(item, pairs) {
|
|
33
|
+
const out = defaults(item);
|
|
34
|
+
for (const pair of pairs) {
|
|
35
|
+
const i = pair.indexOf('=');
|
|
36
|
+
if (i < 0) throw new Error(`--set needs key=value, got "${pair}"`);
|
|
37
|
+
const k = pair.slice(0, i), raw = pair.slice(i + 1);
|
|
38
|
+
if (!(item.params ?? []).some((p) => p.name === k)) throw new Error(`"${item.id}" has no param "${k}" (params: ${(item.params ?? []).map((p) => p.name).join(', ')})`);
|
|
39
|
+
try { out[k] = JSON.parse(raw); } catch { out[k] = raw; }
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── per type ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const gf = (family, weights) => `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, '+')}${weights?.length ? `:wght@${[...new Set(weights)].sort((a, b) => a - b).join(';')}` : ''}&display=swap`;
|
|
47
|
+
|
|
48
|
+
/** A move file with the values written in as its defaults (only inside the exported function's options). */
|
|
49
|
+
function moveFile(item, content, values) {
|
|
50
|
+
const name = item.export, start = content.indexOf(`export function ${name}(`);
|
|
51
|
+
if (start < 0) return content;
|
|
52
|
+
const end = content.indexOf(') {', start);
|
|
53
|
+
let sig = content.slice(start, end);
|
|
54
|
+
for (const p of changed(item, values)) sig = sig.replace(new RegExp(`(\\b${p.name}\\s*=\\s*)(\\[[^\\]]*\\]|'[^']*'|"[^"]*"|[^,}]+?)(\\s*)(?=[,}])`), `$1${literal(values[p.name])}$3`);
|
|
55
|
+
return content.slice(0, start) + sig + content.slice(end);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function backgroundEntry(item, values) {
|
|
59
|
+
const e = item.install.entry ?? {};
|
|
60
|
+
return { ...e, props: { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function paletteTokens(item, values) {
|
|
64
|
+
const e = item.install.entry ?? {};
|
|
65
|
+
const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
|
|
66
|
+
return { ...e, colors, ramp: values.ramp ?? e.ramp };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fontTokens(item, values) {
|
|
70
|
+
const e = item.install.entry ?? {};
|
|
71
|
+
const f = e.fonts ?? {}, w = values.displayWeight ?? e.type?.display?.weight ?? 400, tr = `${values.tracking ?? parseFloat(e.type?.display?.tracking ?? '0')}em`;
|
|
72
|
+
const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; return { ...f[role], family, weights, css: gf(family, weights) }; };
|
|
73
|
+
const fonts = { display: pick('display', [w]), text: pick('text', f.text?.weights ?? [400, 500]), mono: pick('mono', f.mono?.weights ?? [400]) };
|
|
74
|
+
const type = { ...e.type, display: { ...e.type?.display, weight: w, tracking: tr }, title: { ...e.type?.title, weight: w, tracking: tr } };
|
|
75
|
+
return { fonts, type };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A recipe's CONFIG object from flat values (proof1Title… → proof[0].title, "1920x1080" → [1920, 1080]). */
|
|
79
|
+
function recipeConfig(values) {
|
|
80
|
+
const cfg = {}, groups = {};
|
|
81
|
+
for (const [k, v] of Object.entries(values)) {
|
|
82
|
+
const m = k.match(/^(proof|step)(\d)(Label|Title|Body)$/);
|
|
83
|
+
if (m) { const g = m[1] === 'proof' ? 'proof' : 'steps'; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
|
|
84
|
+
if (k === 'format') cfg.format = String(v).split('x').map(Number);
|
|
85
|
+
else cfg[k] = v === '' ? null : v;
|
|
86
|
+
}
|
|
87
|
+
return { ...cfg, ...groups };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recipeScene(content, values) {
|
|
91
|
+
const cfg = recipeConfig(values);
|
|
92
|
+
const start = content.indexOf('const CONFIG = {');
|
|
93
|
+
if (start < 0) return content;
|
|
94
|
+
const end = content.indexOf('\n};', start) + 3;
|
|
95
|
+
const body = Object.entries(cfg).map(([k, v]) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
|
|
96
|
+
? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
|
|
97
|
+
: ` ${k}: ${v === null ? 'null' : literal(v)},`)).join('\n');
|
|
98
|
+
return `${content.slice(0, start)}const CONFIG = {\n${body}\n};${content.slice(end)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── the public API ──────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Applies values to a resolved resource (files with content): returns the files and install entry that
|
|
105
|
+
* `pamo add --set` writes, the same ones the site's File / entry tabs show.
|
|
106
|
+
*/
|
|
107
|
+
export function apply(item, values) {
|
|
108
|
+
const v = { ...defaults(item), ...values };
|
|
109
|
+
const files = (item.files ?? []).map((f) => {
|
|
110
|
+
if (f.content === undefined) return f;
|
|
111
|
+
if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
|
|
112
|
+
if (item.type === 'recipe' && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
|
|
113
|
+
return f;
|
|
114
|
+
});
|
|
115
|
+
let entry = item.install?.entry;
|
|
116
|
+
if (item.type === 'background') entry = backgroundEntry(item, v);
|
|
117
|
+
if (item.type === 'palette') entry = paletteTokens(item, v);
|
|
118
|
+
if (item.type === 'font-pair') entry = fontTokens(item, v);
|
|
119
|
+
return { files, install: { ...item.install, ...(entry ? { entry } : {}) } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The code tabs, the change count and the agent prompt for a resource at these values. */
|
|
123
|
+
export function code(item, values, { showAll = false, site = 'https://paper-motion.app' } = {}) {
|
|
124
|
+
const v = { ...defaults(item), ...values }, diff = changed(item, v), out = apply(item, v);
|
|
125
|
+
const args = setArgs(item, v);
|
|
126
|
+
const agent = `Use the paper-motion resource "${item.id}" (${site}/r/${item.id}.json) in my video. Install it with: npx paper-motion add ${item.id}${args.length ? ` ${args.join(' ')}` : ''}`;
|
|
127
|
+
let tabs = [];
|
|
128
|
+
if (MOVE_TYPES.includes(item.type)) {
|
|
129
|
+
const listed = showAll ? (item.params ?? []).filter((p) => !p.hidden) : diff;
|
|
130
|
+
const head = ((item.signature ?? '').match(/\(([^{)]*)/)?.[1] ?? 'a, b, p').replace(/,\s*$/, '').trim();
|
|
131
|
+
const file = out.files.find((f) => f.content !== undefined);
|
|
132
|
+
const opts = listed.length ? `, { ${listed.map((p) => `${p.name}: ${literal(v[p.name])}`).join(', ')} }` : '';
|
|
133
|
+
tabs = [
|
|
134
|
+
{ key: 'usage', label: 'Usage', lang: 'js', code: `import { ${item.export} } from './${file?.path ?? `moves/${item.id}.js`}';\n\n// in s.update(t)\n${item.export}(${head}${opts});` },
|
|
135
|
+
{ key: 'file', label: 'File', lang: 'js', code: file?.content ?? '' },
|
|
136
|
+
];
|
|
137
|
+
} else if (item.type === 'background') {
|
|
138
|
+
const e = out.install.entry, shader = e.shader;
|
|
139
|
+
tabs = [
|
|
140
|
+
{ key: 'entry', label: 'set.json entry', lang: 'json', code: JSON.stringify({ id: item.id, ...e }, null, 2) },
|
|
141
|
+
{ key: 'usage', label: 'Usage', lang: 'js', code: `import { loadBrand } from 'paper-motion/brand';\n\nconst B = await loadBrand();\nB.shader(s, '#bg', '${item.id}');` },
|
|
142
|
+
{ key: 'react', label: 'Paper React', lang: 'jsx', code: `import { ${shader} } from '@paper-design/shaders-react';\n\n<${shader}\n${Object.entries(e.props).map(([k, x]) => ` ${k}={${typeof x === 'string' ? JSON.stringify(x) : JSON.stringify(x)}}`).join('\n')}\n style={{ width: '100%', height: '100%' }}\n/>` },
|
|
143
|
+
];
|
|
144
|
+
} else if (item.type === 'palette') {
|
|
145
|
+
const e = out.install.entry;
|
|
146
|
+
tabs = [
|
|
147
|
+
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify({ colors: e.colors, ramp: e.ramp, mode: e.mode }, null, 2) },
|
|
148
|
+
{ key: 'css', label: 'CSS variables', lang: 'css', code: `:root {\n${Object.entries(e.colors).map(([r, c]) => ` --${r}: ${c.hex};`).join('\n')}\n${(e.ramp ?? []).map((h, i) => ` --ramp-${i + 1}: ${h};`).join('\n')}\n}` },
|
|
149
|
+
];
|
|
150
|
+
} else if (item.type === 'font-pair') {
|
|
151
|
+
const e = out.install.entry, f = e.fonts;
|
|
152
|
+
const cat = (fam) => (/mono|code/i.test(fam) ? 'monospace' : /serif|playfair|fraunces|garamond|newsreader|instrument/i.test(fam) && !/sans/i.test(fam) ? 'serif' : 'sans-serif');
|
|
153
|
+
tabs = [
|
|
154
|
+
{ key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
|
|
155
|
+
{ 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}` },
|
|
156
|
+
];
|
|
157
|
+
} else if (item.type === 'recipe') {
|
|
158
|
+
const scene = out.files.find((f) => /scene\.html$/.test(f.path));
|
|
159
|
+
tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
|
|
160
|
+
}
|
|
161
|
+
return { tabs, changed: diff.length, agent, setArgs: args };
|
|
162
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A board: 2–4 labelled options the agent proposes, carried in a paper-motion.app link so the user can see them
|
|
2
|
+
// live in their own look, tune one and choose. Nothing reaches a server: the payload travels in the URL fragment.
|
|
3
|
+
// https://paper-motion.app/b#<base64url(deflate-raw(JSON))>
|
|
4
|
+
// This module runs in Node (`pamo board --url`) and in the browser (the /b page), so it has no Node imports: it
|
|
5
|
+
// picks zlib or CompressionStream at call time. Plain ESM; the site copies it next to customize.mjs.
|
|
6
|
+
|
|
7
|
+
// (lib/board.mjs is the other kind of board: the rendered one, stills and clips composed by ffmpeg.)
|
|
8
|
+
|
|
9
|
+
export const BOARD_VERSION = 1;
|
|
10
|
+
export const MAX_PAYLOAD = 8 * 1024;
|
|
11
|
+
const LABELS = ['A', 'B', 'C', 'D'];
|
|
12
|
+
const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
|
|
16
|
+
* look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
|
|
17
|
+
* options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string }> }} Board
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
|
|
21
|
+
export function validateBoard(b, index) {
|
|
22
|
+
const errs = [];
|
|
23
|
+
if (!b || typeof b !== 'object') return ['not an object'];
|
|
24
|
+
if (b.v !== BOARD_VERSION) errs.push(`v must be ${BOARD_VERSION}`);
|
|
25
|
+
if (!['resource', 'kit'].includes(b.kind)) errs.push('kind must be "resource" or "kit"');
|
|
26
|
+
if (!b.title || typeof b.title !== 'string') errs.push('title is required');
|
|
27
|
+
if (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4) errs.push('2 to 4 options');
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const [i, o] of (b.options ?? []).entries()) {
|
|
30
|
+
const at = `options[${i}]`;
|
|
31
|
+
if (!LABELS.includes(o.label)) errs.push(`${at}.label must be A–D`);
|
|
32
|
+
if (seen.has(o.label)) errs.push(`${at}.label "${o.label}" repeats`);
|
|
33
|
+
seen.add(o.label);
|
|
34
|
+
if (b.kind === 'resource') {
|
|
35
|
+
if (!o.id) errs.push(`${at}.id is required`);
|
|
36
|
+
else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
|
|
37
|
+
} else {
|
|
38
|
+
if (!Array.isArray(o.items) || !o.items.length) errs.push(`${at}.items is required for a kit board`);
|
|
39
|
+
for (const it of o.items ?? []) if (index && !index[it.id]) errs.push(`${at}: "${it.id}" is not in the registry`);
|
|
40
|
+
}
|
|
41
|
+
if (o.values && typeof o.values !== 'object') errs.push(`${at}.values must be an object`);
|
|
42
|
+
}
|
|
43
|
+
if (b.kind === 'resource' && index) {
|
|
44
|
+
const types = new Set(b.options.map((o) => index[o.id]?.type).filter(Boolean));
|
|
45
|
+
if (types.size > 1) errs.push(`a resource board shows one type at a time (got ${[...types].join(', ')})`);
|
|
46
|
+
}
|
|
47
|
+
for (const k of ['palette', 'font']) { const l = b.look?.[k]; if (l && index && !index[l.id]) errs.push(`look.${k} "${l.id}" is not in the registry`); }
|
|
48
|
+
return errs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── compression, both runtimes ──────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const b64url = {
|
|
54
|
+
enc: (bytes) => (typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64url') : btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')),
|
|
55
|
+
dec: (s) => { const b = s.replace(/-/g, '+').replace(/_/g, '/'); if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b, 'base64')); const bin = atob(b); return Uint8Array.from(bin, (c) => c.charCodeAt(0)); },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function deflate(bytes) {
|
|
59
|
+
if (typeof CompressionStream === 'function') {
|
|
60
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
|
|
61
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
62
|
+
}
|
|
63
|
+
const { deflateRawSync } = await import('node:zlib');
|
|
64
|
+
return new Uint8Array(deflateRawSync(bytes));
|
|
65
|
+
}
|
|
66
|
+
async function inflate(bytes) {
|
|
67
|
+
if (typeof DecompressionStream === 'function') {
|
|
68
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
69
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
70
|
+
}
|
|
71
|
+
const { inflateRawSync } = await import('node:zlib');
|
|
72
|
+
return new Uint8Array(inflateRawSync(bytes));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Board → the fragment string (no leading #). Throws when invalid or over the size budget. */
|
|
76
|
+
export async function encodeBoard(board, index) {
|
|
77
|
+
const errs = validateBoard(board, index);
|
|
78
|
+
if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
|
|
79
|
+
const json = JSON.stringify(board);
|
|
80
|
+
if (json.length > MAX_PAYLOAD) throw new Error(`board: ${json.length} bytes of JSON, the limit is ${MAX_PAYLOAD}`);
|
|
81
|
+
return b64url.enc(await deflate(new TextEncoder().encode(json)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fragment string → board. Throws on a bad or oversized payload. */
|
|
85
|
+
export async function decodeBoard(fragment) {
|
|
86
|
+
const s = fragment.replace(/^#/, '');
|
|
87
|
+
if (!s) throw new Error('empty board');
|
|
88
|
+
if (s.length > MAX_PAYLOAD * 2) throw new Error('board link too long');
|
|
89
|
+
const json = new TextDecoder().decode(await inflate(b64url.dec(s)));
|
|
90
|
+
const board = JSON.parse(json);
|
|
91
|
+
const errs = validateBoard(board);
|
|
92
|
+
if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
|
|
93
|
+
return board;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const boardUrl = (site, fragment) => `${site.replace(/\/$/, '')}/b#${fragment}`;
|
|
97
|
+
|
|
98
|
+
// ── the reply line ──────────────────────────────────────────────
|
|
99
|
+
// What the user pastes back in the chat. One line, starts with "paper-motion:", so an agent can spot and parse it.
|
|
100
|
+
// paper-motion: A · mesh-sage chose A as proposed
|
|
101
|
+
// paper-motion: B · mesh-sage --set speed=0.8 --set colors=[…] chose B, tuned
|
|
102
|
+
// paper-motion: none of bg-1, show me calmer ones none; free text after the comma
|
|
103
|
+
// paper-motion: kit B · cobalt geist mesh-graphite whip-pan a kit board choice
|
|
104
|
+
// `reply` (the agent's name for the decision) is appended as "(bg-1)" when present.
|
|
105
|
+
|
|
106
|
+
export function replyLine(board, choice) {
|
|
107
|
+
const tag = board.reply ? ` (${board.reply})` : '';
|
|
108
|
+
if (choice.none) return `paper-motion: none of ${board.reply ?? `"${board.title}"`}${choice.text ? `, ${choice.text}` : ''}`;
|
|
109
|
+
const o = board.options.find((x) => x.label === choice.label);
|
|
110
|
+
if (!o) throw new Error(`no option ${choice.label}`);
|
|
111
|
+
if (board.kind === 'kit') return `paper-motion: kit ${o.label} · ${o.items.map((i) => i.id).join(' ')}${tag}`;
|
|
112
|
+
const sets = (choice.setArgs ?? []).join(' ');
|
|
113
|
+
return `paper-motion: ${o.label} · ${o.id}${sets ? ` ${sets}` : ''}${tag}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parses a reply line the user pasted. Returns null when the text is not one. */
|
|
117
|
+
export function parseReply(text) {
|
|
118
|
+
const m = String(text).match(/paper-motion:\s*(.+)$/m);
|
|
119
|
+
if (!m) return null;
|
|
120
|
+
const body = m[1].trim(), tag = body.match(/\(([^()]+)\)\s*$/)?.[1] ?? null, core = body.replace(/\s*\([^()]+\)\s*$/, '');
|
|
121
|
+
if (core.startsWith('none of ')) { const rest = core.slice(8); const i = rest.indexOf(','); return { none: true, reply: tag ?? rest.slice(0, i < 0 ? undefined : i).trim(), text: i < 0 ? '' : rest.slice(i + 1).trim() }; }
|
|
122
|
+
const kit = core.match(/^kit ([A-D]) · (.+)$/);
|
|
123
|
+
if (kit) return { kind: 'kit', label: kit[1], ids: kit[2].split(/\s+/), reply: tag };
|
|
124
|
+
const one = core.match(/^([A-D]) · (\S+)(.*)$/);
|
|
125
|
+
if (!one) return null;
|
|
126
|
+
const sets = [...one[3].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]);
|
|
127
|
+
return { kind: 'resource', label: one[1], id: one[2], set: sets, reply: tag };
|
|
128
|
+
}
|