// Record a demo video of each direction: index, scroll, then a few different kinds of post. // node scripts/record.mjs [run-id-prefix...] [--headed] [--only=] // Needs the app server running (directions fetch ../content/posts.json from it). // Writes videos//.mp4 import { chromium } from 'playwright'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; const ROOT = resolve(import.meta.dirname, '..'); const RUNS = join(ROOT, 'runs'); const OUT = join(ROOT, 'videos'); const ORIGIN = process.env.ORIGIN ?? 'http://localhost:4321'; const SIZE = { width: 1440, height: 900 }; const CONCURRENCY = Number(process.env.CONCURRENCY ?? 1); // parallel recordings lag WebGL pages // Image-heavy, code-heavy, and a tiny note: the three shapes most likely to break a layout. const POSTS = ['fig-gen-making-generative-art-in-figma', 'ferrotype', 'the-black-magic-of-canvas-and-text-editing']; const RESERVED = new Set(['content', 'inspo']); const args = process.argv.slice(2); const headed = args.includes('--headed'); const only = args.find(a => a.startsWith('--only='))?.slice(7); const prefixes = args.filter(a => !a.startsWith('--')); const jobs = readdirSync(RUNS) .filter(r => !prefixes.length || prefixes.some(p => r.startsWith(p))) .flatMap(run => readdirSync(join(RUNS, run)) .filter(d => !RESERVED.has(d) && !d.startsWith('.') && !d.startsWith('_') && existsSync(join(RUNS, run, d, 'index.html'))) .filter(d => !only || d === only) .map(dir => ({ run, dir }))); // The full Chromium build's new headless mode can use the GPU via Metal (~80fps on WebGL pages); // the default headless shell falls back to SwiftShader, which manages under 1fps. const browser = await chromium.launch({ headless: !headed, channel: 'chromium', args: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'], }); /** Smooth wheel scroll at the center of the viewport, so scroll-driven effects fire naturally. */ async function scroll(page, screens, ms) { await page.mouse.move(SIZE.width / 2, SIZE.height / 2, { steps: 10 }); const steps = Math.round(ms / 40); const dy = (screens * SIZE.height) / steps; for (let i = 0; i < steps; i++) { await page.mouse.wheel(0, dy); await page.waitForTimeout(40); } } /** Sweep the mouse across the page to trigger hover and pointer-reactive effects. */ async function sweep(page) { const { width: w, height: h } = SIZE; for (const [x, y] of [[w * 0.2, h * 0.3], [w * 0.8, h * 0.4], [w * 0.6, h * 0.75], [w * 0.3, h * 0.6], [w * 0.5, h * 0.5]]) { await page.mouse.move(x, y, { steps: 25 }); await page.waitForTimeout(150); } } async function record({ run, dir }) { const tmp = join(OUT, '.tmp', `${run}-${dir}`); const context = await browser.newContext({ viewport: SIZE, recordVideo: { dir: tmp, size: SIZE }, deviceScaleFactor: 1 }); const page = await context.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); const url = `${ORIGIN}/runs/${run}/${dir}/index.html`; try { await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); await page.waitForTimeout(3000); // intro animations await sweep(page); await scroll(page, 3, 5000); await page.waitForTimeout(800); await scroll(page, -3, 1500); await page.waitForTimeout(800); for (const slug of POSTS) { await page.evaluate(s => { location.hash = s; }, slug); await page.waitForTimeout(2500); await sweep(page); await scroll(page, 2.5, 4000); await page.waitForTimeout(1000); } await page.evaluate(() => { location.hash = ''; }); await page.waitForTimeout(2500); } catch (err) { errors.push(`tour: ${err.message.split('\n')[0]}`); } const video = page.video(); await context.close(); // flushes the video const webm = await video.path(); mkdirSync(join(OUT, run), { recursive: true }); const mp4 = join(OUT, run, `${dir}.mp4`); execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', webm, '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4]); rmSync(tmp, { recursive: true, force: true }); const mb = (statSync(mp4).size / 1e6).toFixed(1); console.log(`${run.slice(0, 19)} ${dir.padEnd(14)} ${mb}MB${errors.length ? ` (${errors.length} page errors: ${errors[0].slice(0, 80)})` : ''}`); } const queue = [...jobs]; await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (queue.length) await record(queue.shift()); })); await browser.close(); rmSync(join(OUT, '.tmp'), { recursive: true, force: true });