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
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# paper-motion
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The render engine behind the [paper-motion](https://github.com/solanodz/paper-motion) agent skills: creative-directed
|
|
4
|
+
videos for people who use coding agents.
|
|
5
|
+
|
|
6
|
+
It renders an HTML scene, a pure function of time, frame by frame in headless Chrome, and encodes it with ffmpeg:
|
|
7
|
+
MP4, 10-bit HEVC, ProRes, WebM or GIF. The same input always gives the same pixels.
|
|
8
|
+
|
|
9
|
+
Most people never call it directly: the skills do. Install the skills with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx skills add solanodz/paper-motion
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
Node ≥ 18.17, ffmpeg, and Google Chrome (or `npx playwright-core install chromium`).
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx paper-motion setup # check ffmpeg and Chrome, fetch what is missing
|
|
23
|
+
npx paper-motion status # what your brand folder has, and the next step
|
|
24
|
+
npx paper-motion new launch --dir brand/pieces --format 9:16 --piece
|
|
25
|
+
npx paper-motion preview brand/pieces/launch/scene.html # live, scrub with ←/→
|
|
26
|
+
npx paper-motion render brand/pieces/launch/scene.html --motion-blur 8 --loudness -14
|
|
27
|
+
npx paper-motion # every command
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Installed globally (`npm i -g paper-motion`), the same commands run as `paper-motion` or the short `pamo`.
|
|
31
|
+
|
|
32
|
+
## Inside a scene
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { scene } from 'paper-motion'; // the timeline, shader layers, sound
|
|
36
|
+
import { wipe, typeOn } from 'paper-motion/moves'; // the moves library
|
|
37
|
+
import { loadBrand } from 'paper-motion/brand'; // the brand folder: colors, type, motion, sound, shaders
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
All 30 [Paper Shaders](https://shaders.paper.design), a library of moves (wipes, pushes, mask reveals, dives into a
|
|
41
|
+
frame, terminal typing), synthesized sound effects or real keystrokes split from your own recording, option boards,
|
|
42
|
+
wordmarks and the brand sheet.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT. Paper Shaders are © Paper Design (Apache-2.0).
|
package/bin/pamo.mjs
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// paper-motion CLI — scaffold, preview, stills and frame-exact renders of Paper Shader scenes.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
// The repo's production skill, when the engine runs from a paper-motion checkout (for `shaders --md`).
|
|
10
|
+
const REPO_SKILL = path.resolve(ENGINE, '..', '..', 'skills', 'make-video');
|
|
11
|
+
|
|
12
|
+
const HELP = `paper-motion — minimal, creative-directed video from code + Paper Shaders
|
|
13
|
+
|
|
14
|
+
Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/engine/bin/pamo.mjs in the repo)
|
|
15
|
+
|
|
16
|
+
setup install engine deps, check ffmpeg + browser
|
|
17
|
+
doctor only check the environment
|
|
18
|
+
new <name> [--dir works] [--format 16:9|9:16|1:1 | --size 1920x1080] [--fps 30] [--duration 10]
|
|
19
|
+
[--from <example> | --piece (wired to brand/) | --still |
|
|
20
|
+
--move <wipe|push|…> [--pace calm|medium|fast] [--text typing|rise|blur|track|plain] [--camera still|drift|travel]
|
|
21
|
+
[--sound none|soft|crisp|airy|ambient]]
|
|
22
|
+
create works/<name>/scene.html (+ brief.md)
|
|
23
|
+
examples list bundled example scenes
|
|
24
|
+
preview <scene.html> [--port 4317] [--no-open]
|
|
25
|
+
live-reloading preview with a scrubber
|
|
26
|
+
still <scene.html> [--t 0,2.5,5 | --count 6] [--out dir | --out file.png] [--dpr 1] [--motion-blur 8]
|
|
27
|
+
PNG stills + contact-sheet.jpg (for critique);
|
|
28
|
+
with one --t and --out file.png, writes just that file
|
|
29
|
+
render <scene.html> [--out file] [--format mp4|hevc|prores|webm|gif|png]
|
|
30
|
+
[--from s] [--to s] [--fps n] [--crf n] [--dpr 1|2] [--workers n] [--audio file]
|
|
31
|
+
[--gif-width 720] [--gif-fps 24] [--no-sfx] [--motion-blur 8] [--shutter 180] [--loudness -16]
|
|
32
|
+
frame-exact render via headless Chromium + ffmpeg
|
|
33
|
+
(sound effects declared with s.sfx() are mixed in)
|
|
34
|
+
sfx <scene.html> [--out file.wav] [--from s] [--to s]
|
|
35
|
+
export only the synthesized sound effects
|
|
36
|
+
board <a.html> <b.html> [...] [--t 2 | --clip 5] [--labels "A · calm|B · loud"] [--cols n] [--out board.png]
|
|
37
|
+
option board for interviews: 2–6 options side by side,
|
|
38
|
+
lettered A, B, C…; a still (.png) or short clips (.mp4).
|
|
39
|
+
Labels split on | (or on commas when there is no |).
|
|
40
|
+
--cols 1 stacks options full width (judge type at real size)
|
|
41
|
+
--sequence plays the clips one after another, with their sound
|
|
42
|
+
(for choices you hear: sound palettes, typing)
|
|
43
|
+
wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking -0.02em]
|
|
44
|
+
[--color #fff] [--size 400] [--pad 0] [--out wordmark.png]
|
|
45
|
+
the name set in its typeface, as a transparent PNG
|
|
46
|
+
cropped to the ink (--pad adds margin, × size)
|
|
47
|
+
sheet [--brand brand] [--fresh] [--serve [--port 4317]] [--width 1440] [--no-pdf]
|
|
48
|
+
the brand sheet: copies the template to brand/sheet/ (once;
|
|
49
|
+
--fresh replaces it), then exports sheet.png and sheet.pdf,
|
|
50
|
+
or serves it live with --serve
|
|
51
|
+
keys <recording> [--out brand/sound/keys] [--min-gap 0.055] [--sensitivity 1]
|
|
52
|
+
split a recording of real typing into single keystrokes;
|
|
53
|
+
then s.sfx(t, 'key', { bank: 'brand/sound/keys' })
|
|
54
|
+
sound-samples [--brand brand] renders each cue of brand/sound.json (and a typing sample) to
|
|
55
|
+
brand/sound/*.wav + sounds.json, for the brand sheet
|
|
56
|
+
shader-set <build|boards|render> [--brand brand] [--only 3,7 | ids] [--t 2] [--motion-blur 4]
|
|
57
|
+
the brand's shader set from brand/shader-set/set.json:
|
|
58
|
+
build writes one scene per entry and review boards;
|
|
59
|
+
boards renders them to PNG (12 per board, numbered);
|
|
60
|
+
render writes each loop to renders/ and records it in set.json
|
|
61
|
+
status [--brand brand] what the brand folder has, and the next step on the main flow
|
|
62
|
+
shaders [--json | --md [--out f]] [name]
|
|
63
|
+
catalog of every Paper shader, params and presets
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
function parse(argv) {
|
|
67
|
+
const out = { _: [] };
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i];
|
|
70
|
+
if (a.startsWith('--')) {
|
|
71
|
+
const eq = a.indexOf('=');
|
|
72
|
+
const k = eq < 0 ? a.slice(2) : a.slice(2, eq), v = eq < 0 ? undefined : a.slice(eq + 1);
|
|
73
|
+
if (v !== undefined) out[k] = v;
|
|
74
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) out[k] = argv[++i];
|
|
75
|
+
else out[k] = true;
|
|
76
|
+
} else out._.push(a);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const args = parse(process.argv.slice(2));
|
|
82
|
+
const cmd = args._[0];
|
|
83
|
+
|
|
84
|
+
async function depsReady() {
|
|
85
|
+
const { moduleDir } = await import('../lib/modules.mjs');
|
|
86
|
+
return ['@paper-design/shaders', '@paper-design/shaders-react', 'playwright-core'].every((m) => moduleDir(m));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function ensureDeps() {
|
|
90
|
+
if (await depsReady()) return;
|
|
91
|
+
console.log('Installing engine dependencies (first run)…');
|
|
92
|
+
const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--legacy-peer-deps'], { cwd: ENGINE, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
93
|
+
if (r.status !== 0) { console.error('npm install failed in', ENGINE); process.exit(1); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function doctor() {
|
|
97
|
+
let ok = true;
|
|
98
|
+
const v = Number(process.versions.node.split('.')[0]);
|
|
99
|
+
console.log(`${v >= 18 ? '✓' : '✗'} node ${process.versions.node}${v >= 18 ? '' : ' (need >= 18.17)'}`); ok &&= v >= 18;
|
|
100
|
+
const ff = spawnSync(process.env.PAPER_MOTION_FFMPEG || 'ffmpeg', ['-version'], { encoding: 'utf8' });
|
|
101
|
+
if (ff.status === 0) {
|
|
102
|
+
const line = ff.stdout.split('\n')[0];
|
|
103
|
+
const hasX265 = /--enable-libx265/.test(ff.stdout);
|
|
104
|
+
console.log(`✓ ${line}${hasX265 ? '' : ' (no libx265: --format hevc unavailable)'}`);
|
|
105
|
+
} else { ok = false; console.log('✗ ffmpeg not found — macOS: brew install ffmpeg · Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg'); }
|
|
106
|
+
const deps = await depsReady();
|
|
107
|
+
console.log(`${deps ? '✓' : '✗'} engine dependencies${deps ? '' : ' — run: setup'}`); ok &&= deps;
|
|
108
|
+
if (deps) {
|
|
109
|
+
const { findBrowser } = await import('../lib/browser.mjs');
|
|
110
|
+
const b = findBrowser();
|
|
111
|
+
console.log(`${b ? '✓' : '✗'} browser ${b ?? '— install Google Chrome or run: npx playwright-core install chromium'}`); ok &&= !!b;
|
|
112
|
+
}
|
|
113
|
+
return ok;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sceneUrl(file, server) {
|
|
117
|
+
const abs = path.resolve(file);
|
|
118
|
+
if (!fs.existsSync(abs)) { console.error(`Scene not found: ${file}`); process.exit(1); }
|
|
119
|
+
const root = rootFor(abs);
|
|
120
|
+
const rel = path.relative(root, abs).split(path.sep).map(encodeURIComponent).join('/');
|
|
121
|
+
return { root, url: (q = '') => `http://127.0.0.1:${server.port}/${rel}${q}` };
|
|
122
|
+
}
|
|
123
|
+
// Serve from the cwd when the scene lives inside it (so ../assets work), else from the scene folder.
|
|
124
|
+
function rootFor(abs) {
|
|
125
|
+
const cwd = process.cwd();
|
|
126
|
+
return abs.startsWith(cwd + path.sep) ? cwd : path.dirname(abs);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function withServer(file, fn, { watch = false, port = 0 } = {}) {
|
|
130
|
+
const { startServer } = await import('../lib/server.mjs');
|
|
131
|
+
const abs = path.resolve(file);
|
|
132
|
+
const server = await startServer({ root: rootFor(abs), port, watch });
|
|
133
|
+
const { url } = sceneUrl(file, server);
|
|
134
|
+
try { return await fn(url, server); } finally { if (!watch) await server.close(); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const workDir = (file) => path.dirname(path.resolve(file));
|
|
138
|
+
const fmtTime = (s) => (s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`);
|
|
139
|
+
|
|
140
|
+
async function main() {
|
|
141
|
+
switch (cmd) {
|
|
142
|
+
case 'setup': {
|
|
143
|
+
await ensureDeps();
|
|
144
|
+
const ok = await doctor();
|
|
145
|
+
if (!ok) process.exit(1);
|
|
146
|
+
console.log('\nReady.');
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'doctor': { process.exit((await doctor()) ? 0 : 1); break; }
|
|
150
|
+
|
|
151
|
+
case 'examples': {
|
|
152
|
+
const dir = path.join(ENGINE, 'examples');
|
|
153
|
+
for (const d of fs.readdirSync(dir)) {
|
|
154
|
+
const brief = path.join(dir, d, 'brief.md');
|
|
155
|
+
const first = fs.existsSync(brief) ? fs.readFileSync(brief, 'utf8').split('\n').find((l) => l.trim() && !l.startsWith('#')) : '';
|
|
156
|
+
console.log(`${d.padEnd(22)} ${first ?? ''}`);
|
|
157
|
+
}
|
|
158
|
+
console.log(`\nfolder: ${dir}\ncopy one: new <name> --from <example>`);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'new': {
|
|
163
|
+
const name = args._[1];
|
|
164
|
+
if (!name) { console.error('Usage: new <name>'); process.exit(1); }
|
|
165
|
+
const dest = path.resolve(args.dir ?? 'works', name);
|
|
166
|
+
if (fs.existsSync(dest)) { console.error(`Already exists: ${dest}`); process.exit(1); }
|
|
167
|
+
const src = args.from ? path.join(ENGINE, 'examples', args.from) : path.join(ENGINE, 'templates', args.still ? 'still' : args.move ? 'move-demo' : args.piece ? 'piece' : 'blank');
|
|
168
|
+
if (!fs.existsSync(src)) { console.error(`No example "${args.from}". Run: examples`); process.exit(1); }
|
|
169
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
170
|
+
if (args.still || args.move) fs.rmSync(path.join(dest, 'brief.md'), { force: true }); // options for a board need no brief
|
|
171
|
+
const scene = path.join(dest, 'scene.html');
|
|
172
|
+
let html = fs.readFileSync(scene, 'utf8');
|
|
173
|
+
const FORMATS = { '16:9': '1920x1080', '9:16': '1080x1920', '1:1': '1080x1080', '4:5': '1080x1350' };
|
|
174
|
+
const size = args.size ?? (args.format ? FORMATS[String(args.format)] : null);
|
|
175
|
+
if (args.format && !FORMATS[String(args.format)]) { console.error(`Unknown --format ${args.format}: use ${Object.keys(FORMATS).join(', ')}`); process.exit(1); }
|
|
176
|
+
if (size) { const [w, h] = String(size).split('x').map(Number); html = html.replace(/const W = \d+, H = \d+/, `const W = ${w}, H = ${h}`).replace(/width:\s*\d+/, `width: ${w}`).replace(/height:\s*\d+/, `height: ${h}`); }
|
|
177
|
+
if (args.fps) html = html.replace(/fps:\s*\d+/, `fps: ${Number(args.fps)}`);
|
|
178
|
+
if (args.duration) html = html.replace(/duration:\s*[\d.]+/, `duration: ${Number(args.duration)}`);
|
|
179
|
+
html = html.replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`);
|
|
180
|
+
if (args.move) for (const [k, v] of [['MOVE', args.move], ['PACE', args.pace], ['TEXT', args.text], ['CAMERA', args.camera], ['SOUND', args.sound]]) if (typeof v === 'string') html = html.replace(new RegExp(`const ${k} = '[^']*'`), `const ${k} = '${v}'`);
|
|
181
|
+
fs.writeFileSync(scene, html);
|
|
182
|
+
console.log(`Created ${path.relative(process.cwd(), dest)}/\n scene.html ← ${args.still || args.move ? 'an option for a board' : 'the piece (pure function of time)'}${fs.existsSync(path.join(dest, 'brief.md')) ? '\n brief.md ← the creative direction, kept in sync with the user' : ''}`);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'preview': {
|
|
187
|
+
await ensureDeps();
|
|
188
|
+
const file = args._[1];
|
|
189
|
+
if (!file) { console.error('Usage: preview <scene.html>'); process.exit(1); }
|
|
190
|
+
const port = Number(args.port ?? 4317);
|
|
191
|
+
const { startServer } = await import('../lib/server.mjs');
|
|
192
|
+
const abs = path.resolve(file);
|
|
193
|
+
let server;
|
|
194
|
+
try { server = await startServer({ root: rootFor(abs), port, watch: true }); }
|
|
195
|
+
catch { server = await startServer({ root: rootFor(abs), port: 0, watch: true }); }
|
|
196
|
+
const { url } = sceneUrl(file, server);
|
|
197
|
+
console.log(`Preview: ${url()}\n(space play/pause · ←/→ frame · shift+←/→ 1s · L loop · H hide UI) — live-reloads on save. Ctrl+C to stop.`);
|
|
198
|
+
if (!args['no-open']) {
|
|
199
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
200
|
+
spawn(opener, [url()], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'still':
|
|
206
|
+
case 'stills': {
|
|
207
|
+
await ensureDeps();
|
|
208
|
+
const file = args._[1];
|
|
209
|
+
if (!file) { console.error('Usage: still <scene.html> [--t 0,1.5] [--count 6]'); process.exit(1); }
|
|
210
|
+
const { renderStills } = await import('../lib/render.mjs');
|
|
211
|
+
const times = args.t !== undefined ? String(args.t).split(',').map(Number) : null;
|
|
212
|
+
const toFile = typeof args.out === 'string' && /\.png$/i.test(args.out);
|
|
213
|
+
if (toFile && times?.length !== 1) { console.error('still: --out file.png needs exactly one --t'); process.exit(1); }
|
|
214
|
+
const os = await import('node:os');
|
|
215
|
+
const outDir = toFile ? fs.mkdtempSync(path.join(os.tmpdir(), 'pm-still-')) : path.resolve(args.out ?? path.join(workDir(file), 'stills'));
|
|
216
|
+
const r = await withServer(file, (url) => renderStills({ url: url('?render'), times, count: args.count ? Number(args.count) : undefined, outDir, dpr: Number(args.dpr ?? 1), sheet: !args['no-sheet'], motionBlur: args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1, shutter: args.shutter ? Number(args.shutter) : 180 }));
|
|
217
|
+
if (toFile) { const dest = path.resolve(args.out); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(r.files[0], dest); fs.rmSync(outDir, { recursive: true, force: true }); r.files = [dest]; }
|
|
218
|
+
for (const f of r.files) console.log(path.relative(process.cwd(), f));
|
|
219
|
+
if (r.sheet) console.log(`contact sheet: ${path.relative(process.cwd(), r.sheet)}`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'render': {
|
|
224
|
+
await ensureDeps();
|
|
225
|
+
const file = args._[1];
|
|
226
|
+
if (!file) { console.error('Usage: render <scene.html> [--out file] [--format mp4]'); process.exit(1); }
|
|
227
|
+
const { renderVideo } = await import('../lib/render.mjs');
|
|
228
|
+
const format = args.format ?? (args.out ? path.extname(args.out).slice(1).replace('mov', 'prores') || 'mp4' : 'mp4');
|
|
229
|
+
const ext = { mp4: 'mp4', hevc: 'mp4', prores: 'mov', webm: 'webm', gif: 'gif', png: 'png' }[format] ?? 'mp4';
|
|
230
|
+
const base = path.basename(path.dirname(path.resolve(file)));
|
|
231
|
+
const out = path.resolve(args.out ?? path.join(workDir(file), 'renders', `${base}${format === 'hevc' ? '-hevc' : ''}.${ext}`));
|
|
232
|
+
let lastPrint = 0;
|
|
233
|
+
const r = await withServer(file, (url) => renderVideo({
|
|
234
|
+
url: url('?render'), out, format,
|
|
235
|
+
workers: args.workers ? Number(args.workers) : undefined,
|
|
236
|
+
dpr: Number(args.dpr ?? 1),
|
|
237
|
+
from: args.from !== undefined ? Number(args.from) : undefined,
|
|
238
|
+
to: args.to !== undefined ? Number(args.to) : undefined,
|
|
239
|
+
fps: args.fps ? Number(args.fps) : undefined,
|
|
240
|
+
crf: args.crf ? Number(args.crf) : undefined,
|
|
241
|
+
audio: args.audio ? path.resolve(args.audio) : undefined,
|
|
242
|
+
sfx: !args['no-sfx'],
|
|
243
|
+
motionBlur: args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1,
|
|
244
|
+
shutter: args.shutter ? Number(args.shutter) : 180,
|
|
245
|
+
gifWidth: args['gif-width'] ? Number(args['gif-width']) : undefined,
|
|
246
|
+
gifFps: args['gif-fps'] ? Number(args['gif-fps']) : undefined,
|
|
247
|
+
onProgress: ({ done, total, elapsed }) => {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
if (now - lastPrint < 500 && done < total) return;
|
|
250
|
+
lastPrint = now;
|
|
251
|
+
const eta = done ? (elapsed / done) * (total - done) : 0;
|
|
252
|
+
process.stdout.write(`\rrendering ${done}/${total} frames · ${fmtTime(elapsed)} · eta ${fmtTime(eta)} `);
|
|
253
|
+
},
|
|
254
|
+
}));
|
|
255
|
+
process.stdout.write('\n');
|
|
256
|
+
console.log(`✓ ${path.relative(process.cwd(), r.out)} — ${r.frames} frames @ ${r.fps}fps, ${r.config.width}×${r.config.height}, ${fmtTime(r.seconds)} (${r.workers} workers)`);
|
|
257
|
+
if (args.loudness !== undefined && ['mp4', 'hevc', 'prores', 'webm'].includes(format)) {
|
|
258
|
+
// one pass of EBU R128 loudness on the audio, video copied untouched
|
|
259
|
+
const lufs = Number(args.loudness === true ? -16 : args.loudness);
|
|
260
|
+
const has = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', out], { encoding: 'utf8' }).stdout.trim();
|
|
261
|
+
if (!has) console.log(' (no audio to normalize)');
|
|
262
|
+
else {
|
|
263
|
+
const tmp = out.replace(/(\.[a-z0-9]+)$/i, '.norm$1');
|
|
264
|
+
const codec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : format === 'prores' ? ['-c:a', 'pcm_s16le'] : ['-c:a', 'aac', '-b:a', '320k'];
|
|
265
|
+
// EBU R128 loudnorm with a little extra peak headroom (AAC encoding of sharp transients can overshoot),
|
|
266
|
+
// then the file is measured and reported as it really is. Sparse sound design with no music or bed often
|
|
267
|
+
// lands a few LU under the target: that is the honest result, not something to crush.
|
|
268
|
+
const measure = (f) => { const e = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', f, '-vn', '-af', 'loudnorm=print_format=json', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 1 << 26 }).stderr; const j = JSON.parse(e.slice(e.lastIndexOf('{'), e.lastIndexOf('}') + 1) || '{}'); return { I: Number(j.input_i), TP: Number(j.input_tp) }; };
|
|
269
|
+
const chain = `loudnorm=I=${lufs}:TP=-2:LRA=11`;
|
|
270
|
+
const n = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', out, '-c:v', 'copy', '-af', chain, ...codec, '-ar', '48000', tmp]);
|
|
271
|
+
if (n.status === 0) {
|
|
272
|
+
fs.renameSync(tmp, out);
|
|
273
|
+
let { I, TP: P } = measure(out);
|
|
274
|
+
// loudnorm's dynamic mode drifts on short or sparse audio; one plain gain step brings it to the target,
|
|
275
|
+
// downward always, upward only as far as the -1.5 dBTP ceiling allows
|
|
276
|
+
let fix = lufs - I;
|
|
277
|
+
if (fix > 0) fix = Math.min(fix, -1.5 - P);
|
|
278
|
+
if (Math.abs(fix) > 0.3) {
|
|
279
|
+
const t2 = out.replace(/(\.[a-z0-9]+)$/i, '.trim$1');
|
|
280
|
+
const g = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', out, '-c:v', 'copy', '-af', `volume=${fix.toFixed(2)}dB`, ...codec, '-ar', '48000', t2]);
|
|
281
|
+
if (g.status === 0) { fs.renameSync(t2, out); ({ I, TP: P } = measure(out)); }
|
|
282
|
+
}
|
|
283
|
+
console.log(` loudness: ${I} LUFS integrated (target ${lufs}), true peak ${P} dBTP${Number(I) < lufs - 1 ? `\n (under the target: sparse sound design is kept rather than crushed; music or a quiet bed brings it up)` : ''}`);
|
|
284
|
+
} else console.error(` loudness pass failed: ${n.stderr}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case 'sfx': {
|
|
291
|
+
await ensureDeps();
|
|
292
|
+
const file = args._[1];
|
|
293
|
+
if (!file) { console.error('Usage: sfx <scene.html> [--out file.wav]'); process.exit(1); }
|
|
294
|
+
const { renderSfx } = await import('../lib/render.mjs');
|
|
295
|
+
const base = path.basename(path.dirname(path.resolve(file)));
|
|
296
|
+
const out = path.resolve(args.out ?? path.join(workDir(file), 'renders', `${base}-sfx.wav`));
|
|
297
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
298
|
+
const r = await withServer(file, (url) => renderSfx({ url: url('?render'), out, from: args.from !== undefined ? Number(args.from) : 0, to: args.to !== undefined ? Number(args.to) : undefined }));
|
|
299
|
+
console.log(r ? `✓ ${path.relative(process.cwd(), r.out)} — ${r.cues} cues` : 'This scene declares no sound effects (s.sfx).');
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case 'board': {
|
|
304
|
+
await ensureDeps();
|
|
305
|
+
const files = args._.slice(1);
|
|
306
|
+
if (files.length < 2 || files.length > 6) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png]'); process.exit(1); }
|
|
307
|
+
const { renderStills, renderVideo } = await import('../lib/render.mjs');
|
|
308
|
+
const { composeBoard } = await import('../lib/board.mjs');
|
|
309
|
+
const os = await import('node:os');
|
|
310
|
+
const clip = args.clip !== undefined ? Number(args.clip === true ? 5 : args.clip) : null;
|
|
311
|
+
const sequence = !!args.sequence;
|
|
312
|
+
if (sequence && !clip) { console.error('board: --sequence needs --clip <seconds>'); process.exit(1); }
|
|
313
|
+
const t = Number(args.t ?? 2);
|
|
314
|
+
const out = path.resolve(args.out ?? (clip ? 'board.mp4' : 'board.png'));
|
|
315
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-board-'));
|
|
316
|
+
const raw = args.labels ? String(args.labels) : '';
|
|
317
|
+
const given = raw ? raw.split(raw.includes('|') ? '|' : ',').map((x) => x.trim()) : [];
|
|
318
|
+
const labels = files.map((_, i) => given[i] ?? String.fromCharCode(65 + i));
|
|
319
|
+
const inputs = [];
|
|
320
|
+
try {
|
|
321
|
+
for (const [i, file] of files.entries()) {
|
|
322
|
+
process.stdout.write(`\roption ${labels[i].split(' · ')[0]} · ${i + 1}/${files.length} `);
|
|
323
|
+
if (clip) {
|
|
324
|
+
const o = path.join(tmp, `${i}.mp4`);
|
|
325
|
+
await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1) }));
|
|
326
|
+
inputs.push(o);
|
|
327
|
+
} else {
|
|
328
|
+
const r = await withServer(file, (url) => renderStills({ url: url('?render'), times: [t], outDir: path.join(tmp, String(i)), sheet: false, dpr: Number(args.dpr ?? 1) }));
|
|
329
|
+
inputs.push(r.files[0]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', inputs[0]], { encoding: 'utf8' });
|
|
333
|
+
const [w, h] = probe.stdout.trim().split(',').map(Number);
|
|
334
|
+
const r = sequence
|
|
335
|
+
? await (await import('../lib/board.mjs')).composeSequence({ inputs, labels, out, tmp, duration: clip })
|
|
336
|
+
: await composeBoard({ inputs, labels, aspect: w / h, out, tmp, duration: clip ?? undefined, cols: args.cols ? Number(args.cols) : undefined });
|
|
337
|
+
console.log(`\r✓ ${path.relative(process.cwd(), r.out)} — ${files.length} options${sequence ? `, played in turn, ${clip * files.length} s` : `, ${r.W}×${r.H}`}`);
|
|
338
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'wordmark': {
|
|
343
|
+
await ensureDeps();
|
|
344
|
+
const text = args._[1];
|
|
345
|
+
if (!text || !args.family) { console.error('Usage: wordmark <text> --family <name> [--css <url> | --file <font>] [--weight 400] [--tracking 0em] [--color #fff] [--out wordmark.png]'); process.exit(1); }
|
|
346
|
+
const { renderWordmark } = await import('../lib/wordmark.mjs');
|
|
347
|
+
const r = await renderWordmark({
|
|
348
|
+
text: String(text), family: String(args.family), css: args.css, file: args.file ? path.resolve(args.file) : undefined,
|
|
349
|
+
weight: Number(args.weight ?? 400), tracking: args.tracking ?? '0em', color: args.color ?? '#ffffff',
|
|
350
|
+
size: Number(args.size ?? 400), pad: Number(args.pad ?? 0), out: path.resolve(args.out ?? 'wordmark.png'),
|
|
351
|
+
});
|
|
352
|
+
console.log(`✓ ${path.relative(process.cwd(), r.out)} — ${r.width}×${r.height}`);
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'sheet': {
|
|
357
|
+
await ensureDeps();
|
|
358
|
+
const brandDir = path.resolve(args.brand ?? 'brand');
|
|
359
|
+
if (!fs.existsSync(brandDir)) { console.error(`No brand folder at ${path.relative(process.cwd(), brandDir) || '.'} (run from the project root, or pass --brand)`); process.exit(1); }
|
|
360
|
+
const sheetDir = path.join(brandDir, 'sheet');
|
|
361
|
+
const page = path.join(sheetDir, 'index.html');
|
|
362
|
+
if (args.fresh || !fs.existsSync(page)) {
|
|
363
|
+
fs.mkdirSync(sheetDir, { recursive: true });
|
|
364
|
+
fs.copyFileSync(path.join(ENGINE, 'templates', 'brand-sheet', 'index.html'), page);
|
|
365
|
+
console.log(`${args.fresh ? 'replaced' : 'created'} ${path.relative(process.cwd(), page)}`);
|
|
366
|
+
}
|
|
367
|
+
if (args.serve) {
|
|
368
|
+
await withServer(page, async (url) => { console.log(`brand sheet: ${url()} (live reload on; Ctrl+C to stop)`); await new Promise(() => {}); }, { watch: true, port: Number(args.port ?? 4317) });
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
const { launch } = await import('../lib/browser.mjs');
|
|
372
|
+
const width = Number(args.width ?? 1440);
|
|
373
|
+
await withServer(page, async (url) => {
|
|
374
|
+
const browser = await launch();
|
|
375
|
+
try {
|
|
376
|
+
const pg = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 });
|
|
377
|
+
const errs = [];
|
|
378
|
+
pg.on('pageerror', (e) => errs.push(e.message));
|
|
379
|
+
await pg.addInitScript(() => { window.__export = true; }); // live samples show their finished state in exports
|
|
380
|
+
await pg.goto(url());
|
|
381
|
+
await pg.waitForFunction(() => window.__sheetReady === true, null, { timeout: 60000 }).catch(() => { throw new Error(`sheet did not finish loading${errs.length ? `: ${errs[0]}` : ''}`); });
|
|
382
|
+
// show every clip at a telling moment (60 % in), not its first second
|
|
383
|
+
await pg.evaluate(async () => { await Promise.all([...document.querySelectorAll('video')].map((v) => new Promise((res) => {
|
|
384
|
+
const go = () => { v.pause(); v.currentTime = (v.duration || 1) * 0.6; v.onseeked = res; setTimeout(res, 3000); };
|
|
385
|
+
v.readyState >= 1 ? go() : (v.onloadedmetadata = go, setTimeout(res, 5000));
|
|
386
|
+
}))); });
|
|
387
|
+
await pg.waitForTimeout(800); // let the live shader draw
|
|
388
|
+
const png = path.join(sheetDir, 'sheet.png');
|
|
389
|
+
await pg.screenshot({ path: png, fullPage: true });
|
|
390
|
+
console.log(`✓ ${path.relative(process.cwd(), png)}`);
|
|
391
|
+
if (!args['no-pdf']) {
|
|
392
|
+
const h = await pg.evaluate(() => document.documentElement.scrollHeight);
|
|
393
|
+
const pdf = path.join(sheetDir, 'sheet.pdf');
|
|
394
|
+
await pg.pdf({ path: pdf, width: `${width}px`, height: `${h}px`, printBackground: true, pageRanges: '1' });
|
|
395
|
+
console.log(`✓ ${path.relative(process.cwd(), pdf)}`);
|
|
396
|
+
}
|
|
397
|
+
if (errs.length) console.error(`page errors:\n ${errs.join('\n ')}`);
|
|
398
|
+
} finally { await browser.close(); }
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'keys': {
|
|
404
|
+
const input = args._[1];
|
|
405
|
+
if (!input) { console.error('Usage: keys <recording> [--out brand/sound/keys]'); process.exit(1); }
|
|
406
|
+
const { extractKeys } = await import('../lib/keys.mjs');
|
|
407
|
+
const out = path.resolve(args.out ?? 'brand/sound/keys');
|
|
408
|
+
const { hits } = extractKeys(path.resolve(input), out, { minGap: Number(args['min-gap'] ?? 0.055), sensitivity: Number(args.sensitivity ?? 1) });
|
|
409
|
+
const heavy = hits.filter((h) => h.kind === 'heavy').length;
|
|
410
|
+
console.log(`✓ ${hits.length} keystrokes (${hits.length - heavy} letters, ${heavy} heavy) → ${path.relative(process.cwd(), out)}/keys.json`);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
case 'sound-samples': {
|
|
415
|
+
const brandDir = path.resolve(args.brand ?? 'brand');
|
|
416
|
+
const cfgFile = path.join(brandDir, 'sound.json');
|
|
417
|
+
if (!fs.existsSync(cfgFile)) { console.error(`No ${path.relative(process.cwd(), cfgFile)} (run the sound-kit skill first)`); process.exit(1); }
|
|
418
|
+
const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8'));
|
|
419
|
+
const { synthesize, writeWav } = await import('../lib/sfx.mjs');
|
|
420
|
+
const outDir = path.join(brandDir, 'sound');
|
|
421
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
422
|
+
const list = [];
|
|
423
|
+
const lowpass = cfg.lowpass ?? 2000;
|
|
424
|
+
// samples keep the brand's balance: each is scaled by its cue's gain against the loudest cue,
|
|
425
|
+
// so a "very quiet" bed plays very quiet on the sheet too
|
|
426
|
+
const gains = Object.values(cfg.cues ?? {}).filter(Boolean).map((c) => c.gain ?? 0.2);
|
|
427
|
+
const top = Math.max(...gains, 1e-6);
|
|
428
|
+
for (const [name, cue] of Object.entries(cfg.cues ?? {})) {
|
|
429
|
+
if (!cue) continue;
|
|
430
|
+
const dur = (cue.dur ?? 0.8) + 0.6;
|
|
431
|
+
writeWav(path.join(outDir, `${name}.wav`), synthesize([{ t: 0.05, ...cue, ...(name === 'bed' ? { dur: Math.min(cue.dur ?? 6, 6) } : {}) }], { duration: name === 'bed' ? 6.2 : dur, lowpass, peak: 0.7 * Math.max(0.08, (cue.gain ?? 0.2) / top) }));
|
|
432
|
+
list.push({ name, file: `${name}.wav` });
|
|
433
|
+
}
|
|
434
|
+
// a typing sample only when the brand types (motion.json text: typing, or keys configured)
|
|
435
|
+
const motionCfg = fs.existsSync(path.join(brandDir, 'motion.json')) ? JSON.parse(fs.readFileSync(path.join(brandDir, 'motion.json'), 'utf8')) : {};
|
|
436
|
+
const k = cfg.keys ?? {};
|
|
437
|
+
const types = motionCfg.text === 'typing' || !!(k && (k.bank || k.style));
|
|
438
|
+
if (!types) { fs.writeFileSync(path.join(outDir, 'sounds.json'), JSON.stringify(list, null, 1) + '\n'); console.log(`✓ ${list.length} samples → ${path.relative(process.cwd(), outDir)}/sounds.json`); break; }
|
|
439
|
+
const line = 'make a video';
|
|
440
|
+
let t = 0.1; const cues = [];
|
|
441
|
+
[...line].forEach((ch, i) => { cues.push({ t, type: 'key', kind: ch === ' ' ? 'space' : 'letter', gain: k.gain ?? 0.16, seed: i + 1, ...(k.bank ? { bank: k.bank } : {}), ...(k.style ? { style: k.style } : {}) }); t += ch === ' ' ? 0.16 : 0.07 + ((i * 37) % 5) / 100; });
|
|
442
|
+
cues.push({ t: t + 0.3, type: 'key', kind: 'enter', gain: (k.gain ?? 0.16) * 1.1, seed: 99, ...(k.bank ? { bank: k.bank } : {}), ...(k.style ? { style: k.style } : {}) });
|
|
443
|
+
writeWav(path.join(outDir, 'typing.wav'), synthesize(cues, { duration: t + 0.8, lowpass, peak: 0.7 }));
|
|
444
|
+
list.push({ name: 'typing', file: 'typing.wav' });
|
|
445
|
+
fs.writeFileSync(path.join(outDir, 'sounds.json'), JSON.stringify(list, null, 1) + '\n');
|
|
446
|
+
console.log(`✓ ${list.length} samples → ${path.relative(process.cwd(), outDir)}/sounds.json`);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
case 'shader-set': {
|
|
451
|
+
await ensureDeps();
|
|
452
|
+
const sub = args._[1];
|
|
453
|
+
const dir = path.join(path.resolve(args.brand ?? 'brand'), 'shader-set');
|
|
454
|
+
const { buildSet, writeSet, shotName } = await import('../lib/shaderset.mjs');
|
|
455
|
+
const built = buildSet(dir);
|
|
456
|
+
for (const m of built.skipped) console.warn(` skipped ${m}`);
|
|
457
|
+
if (sub === 'build') { console.log(`✓ ${built.shots.length} shots, ${built.boards.length} boards in ${path.relative(process.cwd(), dir)}`); break; }
|
|
458
|
+
if (sub === 'boards') {
|
|
459
|
+
const { renderStills } = await import('../lib/render.mjs');
|
|
460
|
+
const os = await import('node:os');
|
|
461
|
+
for (const b of built.boards) {
|
|
462
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-set-'));
|
|
463
|
+
const r = await withServer(b, (url) => renderStills({ url: url('?render'), times: [Number(args.t ?? 2)], outDir: tmp, sheet: false }));
|
|
464
|
+
const png = b.replace(/\.html$/, '.png');
|
|
465
|
+
fs.copyFileSync(r.files[0], png); fs.rmSync(tmp, { recursive: true, force: true });
|
|
466
|
+
console.log(`✓ ${path.relative(process.cwd(), png)}`);
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (sub === 'render') {
|
|
471
|
+
const { renderVideo } = await import('../lib/render.mjs');
|
|
472
|
+
const only = args.only ? String(args.only).split(',').map((x) => x.trim()) : null;
|
|
473
|
+
const pick = built.shots.filter(({ entry, index }) => !only || only.includes(String(index + 1)) || only.includes(entry.id));
|
|
474
|
+
fs.mkdirSync(path.join(dir, 'renders'), { recursive: true });
|
|
475
|
+
for (const { entry, index, file } of pick) {
|
|
476
|
+
const out = path.join(dir, 'renders', `${shotName(entry, index)}.mp4`);
|
|
477
|
+
await withServer(file, (url) => renderVideo({ url: url('?render'), out, sfx: false, crf: 16, motionBlur: Number(args['motion-blur'] ?? 4) }));
|
|
478
|
+
built.set.entries[index].video = `renders/${shotName(entry, index)}.mp4`;
|
|
479
|
+
writeSet(dir, built.set);
|
|
480
|
+
console.log(`✓ ${path.relative(process.cwd(), out)}`);
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
console.error('Usage: shader-set <build|boards|render> [--brand brand] [--only 3,7]'); process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
case 'status': {
|
|
488
|
+
const B = path.resolve(args.brand ?? 'brand');
|
|
489
|
+
const has = (f) => fs.existsSync(path.join(B, f));
|
|
490
|
+
const filled = (f) => has(f) && fs.readFileSync(path.join(B, f), 'utf8').replace(/^#.*$/gm, '').replace(/^## Open[\s\S]*$/m, '').trim().length > 40;
|
|
491
|
+
const n = (dir, re) => (has(dir) ? fs.readdirSync(path.join(B, dir)).filter((f) => re.test(f)).length : 0);
|
|
492
|
+
let setN = 0, setR = 0;
|
|
493
|
+
if (has('shader-set/set.json')) { const j = JSON.parse(fs.readFileSync(path.join(B, 'shader-set/set.json'), 'utf8')); const e = Array.isArray(j) ? j : j.entries ?? []; setN = e.length; setR = e.filter((x) => x.video && fs.existsSync(path.join(B, 'shader-set', x.video))).length; }
|
|
494
|
+
const piecesDone = has('pieces') ? fs.readdirSync(path.join(B, 'pieces')).filter((p) => fs.existsSync(path.join(B, 'pieces', p, 'renders')) && fs.readdirSync(path.join(B, 'pieces', p, 'renders')).some((f) => f.endsWith('.mp4') && !/test/.test(f))).length : 0;
|
|
495
|
+
const steps = [
|
|
496
|
+
['identity', filled('IDENTITY.md'), 'IDENTITY.md'],
|
|
497
|
+
['visual-system', has('tokens.json') && filled('VISUAL.md'), 'VISUAL.md + tokens.json' + (has('assets/wordmark-on-light.png') || has('assets/wordmark-on-dark.png') ? ' + wordmark' : '')],
|
|
498
|
+
['brand-sheet', has('sheet/sheet.png'), 'sheet/sheet.png'],
|
|
499
|
+
['motion-style', has('motion.json'), 'MOTION.md + motion.json' + (n('motion', /\.mp4$/) ? ` + ${n('motion', /\.mp4$/)} move clips` : '')],
|
|
500
|
+
['sound-kit', has('sound.json'), 'SOUND.md + sound.json' + (has('sound/keys/keys.json') ? ' + keystroke bank' : '')],
|
|
501
|
+
['shader-set', setN > 0, `shader-set: ${setN} entries, ${setR} rendered${setR < setN ? ' (render the rest: shader-set render)' : ''}`],
|
|
502
|
+
['make-video', piecesDone > 0, `pieces: ${piecesDone} rendered${n('pieces', /^[^.]/) > piecesDone ? `, ${n('pieces', /^[^.]/) - piecesDone} in progress` : ''}`],
|
|
503
|
+
];
|
|
504
|
+
if (!fs.existsSync(B)) { console.log(`No brand folder at ${path.relative(process.cwd(), B) || '.'} yet. Start with: identity`); break; }
|
|
505
|
+
for (const [skill, done, what] of steps) console.log(`${done ? '✓' : '·'} ${skill.padEnd(14)} ${done ? what : ''}`);
|
|
506
|
+
console.log(` decisions ${n('decisions', /\.md$/)}`);
|
|
507
|
+
const next = steps.find(([, done]) => !done);
|
|
508
|
+
console.log(next ? `\nnext: ${next[0]}` : '\nThe brand is complete: make another piece with make-video, or revisit any part.');
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
case 'shaders': {
|
|
513
|
+
await ensureDeps();
|
|
514
|
+
const { buildCatalog, catalogMarkdown } = await import('../lib/catalog.mjs');
|
|
515
|
+
const cat = await buildCatalog();
|
|
516
|
+
const version = JSON.parse(fs.readFileSync(path.join((await import('../lib/modules.mjs')).moduleDir('@paper-design/shaders-react'), 'package.json'), 'utf8')).version;
|
|
517
|
+
const name = args._[1];
|
|
518
|
+
const list = name ? cat.filter((s) => s.name.toLowerCase() === name.toLowerCase()) : cat;
|
|
519
|
+
if (args.md) {
|
|
520
|
+
const md = catalogMarkdown(cat, version);
|
|
521
|
+
const dest = path.resolve(args.out ?? (fs.existsSync(REPO_SKILL) ? path.join(REPO_SKILL, 'references', 'shaders.md') : 'shaders.md'));
|
|
522
|
+
fs.writeFileSync(dest, md);
|
|
523
|
+
console.log(`wrote ${dest}`);
|
|
524
|
+
} else if (args.json || name) console.log(JSON.stringify(list, null, 2));
|
|
525
|
+
else for (const s of cat) console.log(`${s.name.padEnd(22)} ${s.animated ? 'anim ' : 'still'} ${s.usesImage ? 'img' : ' '} ${s.description.split('. ')[0]}`);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
default:
|
|
530
|
+
console.log(HELP);
|
|
531
|
+
if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
main().catch((e) => { console.error(`\n✗ ${e.message}`); process.exit(1); });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Brief
|
|
2
|
+
|
|
3
|
+
Warm, quiet 4:5 loop: a breathing field of dust-colored light and a single italic word.
|
|
4
|
+
|
|
5
|
+
**Intent** — slow the viewer's breathing down for twelve seconds.
|
|
6
|
+
**Format** — 1080×1350 · 30fps · 12s · seamless loop · social feed / screen.
|
|
7
|
+
**Palette** — near-black umber ground `#1b1a17`, clay `#3d3328`, sand `#a8927a`, bone `#e9dccb`.
|
|
8
|
+
**Material** — MeshGradient with grain (mixer + overlay) so it reads like film, not like a UI gradient.
|
|
9
|
+
**Type** — one word, italic old-style serif, wide tracking; a tiny mono footnote.
|
|
10
|
+
**Motion** — one oscillation of scale per loop (period = duration). Letters arrive with blur→focus, leave in reverse order.
|
|
11
|
+
**Structure** — 0–1s field only · 1.2–3s word arrives · 4–8s hold · 8.2–10s word leaves · 10–12s field returns to frame 0.
|
|
Binary file
|