// render.mjs: drive studio.html in headless Chrome. // node render.mjs --sheet=23,23.5,24 [--cols=3] [--w=640] --out=out/check.jpg contact sheet (fast visual check) // node render.mjs --stills=0.8,3,23.8 --out=out/test full-res PNG stills // node render.mjs --clip=0:6 --fps=24 --out=out/test.mp4 short clip with audio // node render.mjs --frames=0:156.6 --workers=4 full-res JPEG frames → out/frames (resumable) // node render.mjs --encode [--out=out/pdoom.mp4] frames + song → MP4 // node render.mjs --loop=recursion [--out=out/loop_recursion] one cycle of a standalone loop (PNGs) // (--loop also works with --sheet, where the times are loop time) import puppeteer from 'puppeteer-core'; import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, existsSync, statSync, renameSync, readdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; })); const CHROME = args.chrome || 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const DUR = 156.6, fps = +(args.fps || 24); const FRAMES_DIR = 'out/frames'; const run = (cmd, a) => new Promise((ok, bad) => { const p = spawn(cmd, a, { stdio: 'inherit' }); p.on('close', c => c ? bad(new Error(cmd + ' exited ' + c)) : ok()); }); if (args.encode) { const out = args.out || 'out/pdoom.mp4', n = readdirSync(FRAMES_DIR).filter(f => f.endsWith('.jpg')).length; console.log(`encoding ${n} frames → ${out}`); await run('ffmpeg', ['-y', '-loglevel', 'error', '-stats', '-framerate', String(fps), '-i', `${FRAMES_DIR}/f%05d.jpg`, '-i', 'assets/pdoom.mp3', '-map', '0:v', '-map', '1:a', '-c:v', 'libx264', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-shortest', out]); console.log('wrote ' + out); process.exit(0); } const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, protocolTimeout: 0, args: ['--allow-file-access-from-files', '--ignore-gpu-blocklist', '--use-angle=d3d11', '--enable-gpu-rasterization', '--window-size=1920,1080', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'] }); async function openPage(tag = '') { const page = await browser.newPage(); page.on('console', m => { if (['error', 'warn'].includes(m.type())) console.log(`[page${tag}]`, m.text()); }); page.on('pageerror', e => console.log(`[page error${tag}]`, e.message)); await page.goto(pathToFileURL(resolve('studio.html')).href + '?render', { waitUntil: 'networkidle0' }); await page.waitForFunction('window.ready === true', { timeout: 60000 }); if (args.loop) await page.evaluate(name => { window.LOOP = LOOPS[name]; }, args.loop); return page; } const frameOf = async (page, t, type, q) => { const url = await page.evaluate((t, type, q) => window.renderAt(t, type, q), t, type, q); return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); }; const times = s => String(s).split(',').map(Number); if (args.sheet) { const page = await openPage(), out = args.out || 'out/sheet.jpg'; mkdirSync(dirname(out), { recursive: true }); const { url, ms } = await page.evaluate((ts, c, w) => window.renderSheet(ts, c, w), times(args.sheet), +(args.cols || 3), +(args.w || 640)); writeFileSync(out, Buffer.from(url.slice(url.indexOf(',') + 1), 'base64')); console.log(`${out} ms/frame: ${ms.join(' ')}`); } else if (args.stills) { const page = await openPage(), out = args.out || 'out/stills'; mkdirSync(out, { recursive: true }); console.log('GPU:', await page.evaluate(() => window.gpuInfo())); for (const s of times(args.stills)) { const t0 = Date.now(), buf = await frameOf(page, s, 'image/png'); const f = `${out}/t${s.toFixed(2).replace('.', '_')}.png`; writeFileSync(f, buf); console.log(`${f} ${Date.now() - t0} ms`); } } else if (args.loop) { // One full cycle of a standalone loop scene as PNGs (t = loop time); frame n equals frame 0, so it isn't rendered. const out = args.out || `out/loop_${args.loop}`, workers = +(args.workers || 3); mkdirSync(out, { recursive: true }); const probe = await openPage(), len = await probe.evaluate(() => window.LOOP.len), n = Math.round(len * fps); await probe.close(); let next = 0; const start = Date.now(); await Promise.all(Array.from({ length: workers }, async (_, w) => { const page = await openPage('#' + w); while (next < n) { const i = next++; writeFileSync(`${out}/l${String(i).padStart(3, '0')}.png`, await frameOf(page, i / fps, 'image/png')); } })); console.log(`${n} loop frames → ${out} (${((Date.now() - start) / n).toFixed(0)} ms/frame)`); } else if (args.frames) { // Parallel, resumable: each worker page pulls the next missing frame index; files are written atomically. const [a, b] = String(args.frames).split(':').map(Number), workers = +(args.workers || 4); mkdirSync(FRAMES_DIR, { recursive: true }); const first = Math.round(a * fps), last = Math.min(Math.ceil(DUR * fps) - 1, Math.round(b * fps) - 1); const todo = []; for (let i = first; i <= last; i++) { const f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`; if (!existsSync(f) || statSync(f).size < 1000) todo.push(i); } console.log(`${todo.length} frames to render (${last - first + 1 - todo.length} already done), ${workers} workers`); let next = 0, done = 0; const start = Date.now(); const work = async w => { const page = await openPage('#' + w); while (next < todo.length) { const i = todo[next++], f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`; const buf = await frameOf(page, i / fps, 'image/jpeg', .94); writeFileSync(f + '.tmp', buf); renameSync(f + '.tmp', f); if (++done % 24 === 0 || done === todo.length) { const el = (Date.now() - start) / 1000; console.log(`frame ${done}/${todo.length} ${(el / done * 1000).toFixed(0)} ms/frame effective eta ${((todo.length - done) * el / done / 60).toFixed(1)} min`); } } }; await Promise.all(Array.from({ length: workers }, (_, w) => work(w))); } else { const page = await openPage(); const [a, b] = args.clip ? String(args.clip).split(':').map(Number) : [0, DUR]; const out = args.out || 'out/clip.mp4'; mkdirSync(dirname(out), { recursive: true }); const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'mjpeg', '-i', '-', '-ss', String(a), '-t', String(b - a), '-i', 'assets/pdoom.mp3', '-map', '0:v', '-map', '1:a', '-c:v', 'libx264', '-preset', 'medium', '-crf', '19', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-shortest', out], { stdio: ['pipe', 'inherit', 'inherit'] }); const n = Math.round((b - a) * fps), start = Date.now(); for (let i = 0; i < n; i++) { const buf = await frameOf(page, a + i / fps, 'image/jpeg', .92); if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r)); if (i % 24 === 0 || i === n - 1) console.log(`frame ${i + 1}/${n} ${((Date.now() - start) / (i + 1)).toFixed(0)} ms/frame`); } ff.stdin.end(); await new Promise(r => ff.on('close', r)); console.log(`wrote ${out}`); } await browser.close();