-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcapture_previews.mjs
More file actions
406 lines (368 loc) · 15.6 KB
/
Copy pathcapture_previews.mjs
File metadata and controls
406 lines (368 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// Generate looping animated-WebP (and static poster) previews for every example
// in this repo, for use as thumbnails in the auto-generated gallery (see
// scripts/build_gallery.py). Canvas-based sketches are recorded as short loops;
// Web Serial / canvas-less examples get a single poster screenshot instead.
//
// Drives a headless browser via Playwright and encodes frames with ffmpeg (both
// expected on PATH; ffmpeg needs libwebp). Runs locally and in CI:
// - Local: PW_CHANNEL=chrome uses your installed Google Chrome (no download)
// - CI: (no channel) uses Playwright's bundled Chromium
//
// Which folders count as "examples" is decided ENTIRELY by build_gallery.py;
// this script asks it via `--list-json` rather than re-walking the tree, so the
// gallery and the previews can never disagree about the example set.
//
// Output (mirrors each example's path under the repo root):
// previews/<rel_path>.webp (animated loop)
// previews/<rel_path>.poster.png (static fallback / reduced-motion)
// previews/manifest.json (content hashes, to skip unchanged examples)
//
// Usage:
// node scripts/capture_previews.mjs [--force] [--only <substr>]
//
// Per-example overrides live in a preview.json next to the example's index.html:
// { "skip": false, "mode": "animated" | "poster",
// "duration": 4, "fps": 15, "delay": 250, "width": 480, "quality": 72 }
import { chromium } from 'playwright';
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
mkdir, rm, readFile, writeFile, readdir,
} from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = path.join(ROOT, 'previews');
const MANIFEST = path.join(OUT_DIR, 'manifest.json');
const PORT = 8123;
// Defaults (overridable per example via preview.json). The capture viewport is
// roughly 16:10; frames are scaled down to `width` at encode time, and the
// gallery card crops to 16:9 via object-fit, so exact source aspect is flexible.
const DEFAULTS = {
mode: 'animated',
duration: 4, // seconds of loop to record
fps: 15,
delay: 250, // ms warm-up before the first frame (let the sketch settle)
width: 480, // output px width
quality: 72, // libwebp q:v
click: true, // click the canvas once before capturing (dismiss "click to start" gates)
// What element to record. Defaults to the first <canvas>; set a CSS selector
// (e.g. an iframe) to record sketches whose canvas lives elsewhere.
captureSelector: null,
// Optional scripted input played WHILE recording, so the loop shows the sketch
// being driven. Shape (all optional):
// { "drag": "orbit" | "horizontal", "keys": ["ArrowRight"], "keyEveryFrames": 6 }
interact: null,
};
const VIEWPORT = { width: 900, height: 560 };
// Categories whose examples need hardware / interaction we can't drive headless
// (no serial device attached), so a static poster of the UI is the best we can
// do. Per-example preview.json can still override this.
const POSTER_CATEGORIES = new Set(['WebSerial']);
const args = process.argv.slice(2);
const FORCE = args.includes('--force');
const ONLY = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
const CHANNEL = process.env.PW_CHANNEL || undefined; // 'chrome' locally, unset in CI
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd, a) => spawnSync(cmd, a, { encoding: 'utf8' });
// ---------- discovery (build_gallery.py is the source of truth) ----------
/** Ask build_gallery.py for the list of examples; map to the capture model. */
function discoverApps() {
const r = sh('python3', ['scripts/build_gallery.py', '--list-json']);
if (r.status !== 0) {
throw new Error(`build_gallery.py --list-json failed: ${r.stderr || r.stdout}`);
}
const entries = JSON.parse(r.stdout);
return entries.map((e) => {
const rel = e.rel_path; // e.g. "WebSerial/p5js/CircleSizeIn"
const dir = path.join(ROOT, rel);
let config = {};
const cfgPath = path.join(dir, 'preview.json');
if (existsSync(cfgPath)) {
try { config = JSON.parse(readFileSync(cfgPath, 'utf8')); }
catch (err) { console.warn(` ! bad preview.json in ${rel}: ${err.message}`); }
}
// Hardware/serial categories default to a poster of their UI, and we don't
// click them (their loaded UI *is* the thumbnail; a click could trip a
// serial-connect prompt). Everything else defaults to an animated capture.
const categoryDefaults = POSTER_CATEGORIES.has(e.category)
? { mode: 'poster', click: false }
: {};
const opts = { ...DEFAULTS, ...categoryDefaults, ...config };
return {
key: rel,
category: e.category,
dir,
url: `http://localhost:${PORT}/${rel}/index.html`,
opts,
skip: config.skip === true,
};
});
}
// ---------- file discovery + hashing ----------
/**
* Recursively list files under `dir` (absolute paths). Dotfiles/dot-dirs are
* skipped so machine-local junk (e.g. macOS `.DS_Store`, which is gitignored)
* doesn't pollute content hashes and make them differ between local and CI.
*/
async function listFiles(dir) {
const out = [];
async function walk(d) {
let entries;
try { entries = await readdir(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.name.startsWith('.')) continue; // .DS_Store, .git, etc.
const p = path.join(d, e.name);
if (e.isDirectory()) await walk(p);
else out.push(p);
}
}
await walk(dir);
return out.sort();
}
/** Stable content hash of a set of files (path-relative-to-ROOT + bytes). */
async function hashFiles(files) {
const h = createHash('sha256');
for (const f of files) {
h.update(path.relative(ROOT, f).replace(/\\/g, '/'));
h.update('\0');
h.update(await readFile(f));
h.update('\0');
}
return h.digest('hex').slice(0, 16);
}
// ---------- capture + encode ----------
/**
* Many sketches gate on a user gesture (audio needs one; games/interactive art
* often start drawing on mousePressed). Dispatch a single click at the center of
* the canvas (or page) so the recorded loop shows the running sketch, not a
* "click to begin" splash. Disable per-example with `"click": false`.
*/
async function maybeClick(page, app) {
if (!app.opts.click) return;
const target = (await page.locator('canvas').count()) > 0
? page.locator('canvas').first()
: page.locator('body');
try {
const box = await target.boundingBox();
if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
} catch { /* non-fatal: capture whatever is on screen */ }
}
async function captureAnimated(page, app, framesDir) {
const { duration, fps, delay } = app.opts;
const frameCount = Math.round(fps * duration);
const intervalMs = 1000 / fps;
const target = page.locator(app.opts.captureSelector || 'canvas').first();
await target.waitFor({ state: 'visible', timeout: 8000 });
await maybeClick(page, app);
await sleep(delay);
// Optional scripted interaction (drag-to-orbit, arrow keys) played as the loop
// records, so e.g. a p5 WEBGL sketch visibly rotates in its preview.
const inter = app.opts.interact;
const box = inter ? await target.boundingBox() : null;
const cx = box ? box.x + box.width / 2 : 0;
const cy = box ? box.y + box.height / 2 : 0;
let dragging = false;
if (inter && box && inter.drag && inter.drag !== 'none') {
await page.mouse.move(cx, cy);
await page.mouse.down(); // grabs the canvas (and focuses it for key input)
dragging = true;
}
const keys = (inter && inter.keys) || [];
const keyEvery = (inter && inter.keyEveryFrames) || 6;
const t0 = Date.now();
for (let i = 0; i < frameCount; i++) {
if (inter && box) {
if (dragging) {
// Sweep one full cycle over the recording so the loop returns near start.
const phase = (i / frameCount) * Math.PI * 2;
const dx = Math.sin(phase) * box.width * (inter.drag === 'horizontal' ? 0.35 : 0.30);
const dy = inter.drag === 'orbit' ? Math.cos(phase) * box.height * 0.16 : 0;
await page.mouse.move(cx + dx, cy + dy, { steps: 3 });
}
if (keys.length && i % keyEvery === 0) {
await page.keyboard.press(keys[Math.floor(i / keyEvery) % keys.length]);
}
}
const drift = Date.now() - t0 - i * intervalMs;
if (drift < 0) await sleep(-drift); // pace toward real-time fps
await target.screenshot({ path: path.join(framesDir, `f_${String(i).padStart(4, '0')}.png`) });
}
if (dragging) await page.mouse.up();
return frameCount;
}
function encodeWebp(framesDir, fps, width, quality, outFile) {
const r = sh('ffmpeg', [
'-y', '-loglevel', 'error',
'-framerate', String(fps),
'-i', path.join(framesDir, 'f_%04d.png'),
'-vf', `scale=${width}:-1:flags=lanczos`,
'-c:v', 'libwebp', '-lossless', '0', '-q:v', String(quality),
'-compression_level', '6', '-loop', '0',
outFile,
]);
if (r.status !== 0) throw new Error(`ffmpeg webp failed: ${r.stderr}`);
}
function encodePoster(srcPng, width, outFile) {
const r = sh('ffmpeg', [
'-y', '-loglevel', 'error', '-i', srcPng,
'-vf', `scale=${width}:-1:flags=lanczos`, outFile,
]);
if (r.status !== 0) throw new Error(`ffmpeg poster failed: ${r.stderr}`);
}
/** Render one example to previews/. Returns the produced mode. */
async function renderApp(browser, app) {
const webpOut = path.join(OUT_DIR, `${app.key}.webp`);
const posterOut = path.join(OUT_DIR, `${app.key}.poster.png`);
await mkdir(path.dirname(webpOut), { recursive: true });
const tmp = path.join(os.tmpdir(), `p5-preview-${app.key.replace(/[\\/]/g, '-')}`);
await rm(tmp, { recursive: true, force: true });
await mkdir(tmp, { recursive: true });
const page = await browser.newPage({
viewport: VIEWPORT,
deviceScaleFactor: 1,
// Auto-grant mic so audio visualizers (getUserMedia) start; the fake device
// (browser launch flags) feeds a synthetic tone the FFT can render.
permissions: ['microphone'],
});
try {
await page.goto(app.url, { waitUntil: 'networkidle', timeout: 30000 });
await page.addStyleTag({ content: '.hint{display:none !important;}' });
// Animated mode still falls back to a poster if there's nothing to record.
let mode = app.opts.mode;
if (mode === 'animated'
&& (await page.locator(app.opts.captureSelector || 'canvas').count()) === 0) {
console.warn(` no capture target → poster: ${app.key}`);
mode = 'poster';
}
if (mode === 'poster') {
// Static thumbnail. For sketches that gate on a gesture (e.g. audio
// visualizers), maybeClick + the warm-up settle let the canvas fill in
// before the screenshot, so the poster shows real content rather than a
// "click to begin" splash. WebSerial keeps click:false (its loaded UI is
// the intended thumbnail), and maybeClick no-ops when click is false.
await maybeClick(page, app);
await sleep(app.opts.delay);
const raw = path.join(tmp, 'page.png');
await page.screenshot({ path: raw });
encodePoster(raw, app.opts.width, posterOut);
await rm(webpOut, { force: true }); // drop any stale animation
return 'poster';
}
// Animated: record the canvas loop, derive the poster from the last frame.
const n = await captureAnimated(page, app, tmp);
encodeWebp(tmp, app.opts.fps, app.opts.width, app.opts.quality, webpOut);
const last = path.join(tmp, `f_${String(n - 1).padStart(4, '0')}.png`);
encodePoster(last, app.opts.width, posterOut);
return 'animated';
} finally {
await page.close();
await rm(tmp, { recursive: true, force: true });
}
}
// ---------- server ----------
function startServer() {
return spawn('python3', ['-m', 'http.server', String(PORT)], {
cwd: ROOT, stdio: 'ignore',
});
}
// ---------- main ----------
/**
* Signature of the output-affecting options, stored in the manifest so a change
* to preview.json (which is excluded from the content hash) still triggers a
* rebuild. Note: NOT compared against the produced mode — an `animated` example
* that falls back to a poster at runtime stores mode:"poster", and we treat it
* as fresh on the next run as long as this signature and the content hash match.
*/
function optsSignature(opts) {
return JSON.stringify({
mode: opts.mode,
captureSelector: opts.captureSelector,
interact: opts.interact,
duration: opts.duration,
fps: opts.fps,
width: opts.width,
quality: opts.quality,
click: opts.click,
});
}
async function main() {
const apps = discoverApps();
let manifest = {};
if (existsSync(MANIFEST)) {
try { manifest = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch { /* rebuild */ }
}
await mkdir(OUT_DIR, { recursive: true });
// Decide what needs work.
const work = [];
for (const app of apps) {
if (ONLY && !app.key.includes(ONLY)) continue;
if (app.skip) {
console.log(`skip ${app.key} (preview.json skip)`);
delete manifest[app.key];
continue;
}
const appFiles = await listFiles(app.dir);
const appHash = await hashFiles(appFiles.filter((f) => !f.endsWith('preview.json')));
const optsSig = optsSignature(app.opts);
const prev = manifest[app.key];
const webpOut = path.join(OUT_DIR, `${app.key}.webp`);
const posterOut = path.join(OUT_DIR, `${app.key}.poster.png`);
// Compare against the mode actually produced last time (prev.mode), so a
// runtime poster-fallback stays cached instead of re-rendering every run.
const expected = prev && prev.mode === 'poster' ? posterOut : webpOut;
const fresh = !FORCE && prev
&& prev.appHash === appHash
// Legacy manifest entries predate optsSig; treat them as opts-fresh so this
// change doesn't force a one-time re-render (and re-commit) of everything.
&& (prev.optsSig === undefined || prev.optsSig === optsSig)
&& existsSync(expected);
if (fresh) { console.log(`ok ${app.key} (unchanged)`); continue; }
work.push({ app, appHash, optsSig });
}
if (work.length === 0) {
console.log('\nAll previews up to date.');
await writeFile(MANIFEST, `${JSON.stringify(sortObj(manifest), null, 2)}\n`);
return;
}
console.log(`\nGenerating ${work.length} preview(s)…`);
const server = startServer();
await sleep(1200); // let the static server come up
const browser = await chromium.launch({
channel: CHANNEL,
headless: true,
// Synthetic audio/video so mic-based sketches (e.g. the Sound visualizers)
// get a running getUserMedia stream and actually animate, and so any
// autoplaying audio starts without a real user gesture.
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required',
],
});
try {
for (const { app, appHash, optsSig } of work) {
const label = `${app.key} [${app.opts.mode}]`;
try {
const t = Date.now();
const mode = await renderApp(browser, app);
manifest[app.key] = { mode, appHash, optsSig };
console.log(`build ${label} (${((Date.now() - t) / 1000).toFixed(1)}s)`);
} catch (e) {
console.error(`FAIL ${label}: ${e.message}`);
process.exitCode = 1;
}
}
} finally {
await browser.close();
server.kill();
}
await writeFile(MANIFEST, `${JSON.stringify(sortObj(manifest), null, 2)}\n`);
console.log(`\nWrote ${path.relative(ROOT, OUT_DIR)}/`);
}
function sortObj(o) {
return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
}
await main();