Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2jFQjbs5WTZ8jTMNUB8XY
158 lines
11 KiB
JavaScript
158 lines
11 KiB
JavaScript
// render.mjs: renders studio.html in headless Chrome. Length and fps come from the page (PROJECT in src/config.js).
|
|
//
|
|
// Look at it (open the images with your image viewer / Read tool):
|
|
// node render.mjs --sheet=0.5,1,1.5,2 [--cols=4] [--w=480] --out=out/check/a.jpg contact sheet of chosen times
|
|
// node render.mjs --strip=2.0:2.5 [--cols=6] [--w=320] --out=out/check/strip.jpg EVERY frame in a stretch (motion)
|
|
// node render.mjs --sheet=2.1,2.2 --crop=760,300,400,400 --w=600 --out=out/check/face.jpg full-res crops (details)
|
|
// node render.mjs --strip=2.0:2.5 --crop-at=960,780,500,400 --out=out/check/feet.jpg crops that follow a WORLD point
|
|
// (x,y in world px, may be page expressions like PLK.MX(1.38); w,h in screen px) through each frame's camera
|
|
// node render.mjs --stills=1.2,3.4 --out=out/stills full-res PNGs
|
|
// Make the video:
|
|
// node render.mjs --clip [--range=0:4] --out=out/video.mp4 straight to MP4 (one worker)
|
|
// node render.mjs --frames [--range=0:8] --workers=4 JPEG frames → out/frames (parallel, resumable)
|
|
// node render.mjs --encode --out=out/video.mp4 out/frames → MP4
|
|
// Standalone loops (LOOPS in the page): add --loop=<name> to any of the above (times are then loop times), or
|
|
// node render.mjs --loop=emotions --png --out=out/loop_emotions one cycle as PNGs (for GIFs)
|
|
// Music: --audio=assets/song.mp3 (or PROJECT.audio) is muxed into --clip and --encode. Other flags: --fps=24,
|
|
// --chrome=<path to Chrome/Chromium>.
|
|
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';
|
|
import { homedir } from 'node:os';
|
|
|
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
|
const CHROMES = [args.chrome, process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
...playwrightChromes()];
|
|
// Chromium builds Playwright downloaded (~/.cache/ms-playwright/chromium-NNNN), newest first
|
|
function playwrightChromes() {
|
|
const dir = `${homedir()}/.cache/ms-playwright`;
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir).filter(n => /^chromium-\d+$/.test(n)).sort((a, b) => b.split('-')[1] - a.split('-')[1])
|
|
.map(n => `${dir}/${n}/chrome-linux64/chrome`);
|
|
}
|
|
const CHROME = CHROMES.find(p => p && existsSync(p));
|
|
if (!CHROME) { console.error('Chrome not found: pass --chrome=<path> or set CHROME_PATH'); process.exit(1); }
|
|
const fps = +(args.fps || 24), 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()); });
|
|
const times = s => String(s).split(',').map(Number);
|
|
const span = s => String(s).split(':').map(Number);
|
|
// comma-separated fields, keeping commas inside parentheses ('PLK.MX(1.38),PLK.WL,500,300'); numbers stay numbers
|
|
const fields = s => { const out = []; let d = 0, cur = ''; for (const ch of String(s)) { if (ch === ',' && !d) { out.push(cur); cur = ''; continue; } d += ch === '(' ? 1 : ch === ')' ? -1 : 0; cur += ch; } out.push(cur); return out.map(v => isNaN(+v) ? v : +v); };
|
|
|
|
if (args.encode) {
|
|
const out = args.out || 'out/video.mp4', n = readdirSync(FRAMES_DIR).filter(f => f.endsWith('.jpg')).length, audio = args.audio;
|
|
console.log(`encoding ${n} frames → ${out}${audio ? ' with ' + audio : ''}`);
|
|
await run('ffmpeg', ['-y', '-loglevel', 'error', '-stats', '-framerate', String(fps), '-i', `${FRAMES_DIR}/f%05d.jpg`,
|
|
...(audio ? ['-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
|
'-c:v', 'libx264', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out]);
|
|
console.log('wrote ' + out);
|
|
process.exit(0);
|
|
}
|
|
|
|
// --soft-gl: no GPU on this machine; render WebGL in software (SwiftShader), which Chrome only allows when asked.
|
|
// --gpu-angle=vulkan|gl-egl: headless Linux on an NVIDIA GPU (e.g. a cloud or cluster node); plain --use-gl=angle gets
|
|
// no WebGL context there. Check which GPU Chrome actually lands on with gpu_probe.mjs.
|
|
const ANGLE = { vulkan: ['--use-angle=vulkan', '--enable-features=Vulkan'], 'gl-egl': ['--use-angle=gl-egl'] };
|
|
if (args['gpu-angle'] && !ANGLE[args['gpu-angle']]) { console.error(`--gpu-angle must be one of ${Object.keys(ANGLE)}`); process.exit(1); }
|
|
const gpu = args['soft-gl'] ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
|
: args['gpu-angle'] ? ANGLE[args['gpu-angle']]
|
|
: process.platform === 'win32' ? ['--use-angle=d3d11'] : process.platform === 'darwin' ? ['--use-angle=metal'] : ['--use-gl=angle'];
|
|
// Ubuntu 23.10+ blocks Chrome's user-namespace sandbox; headless rendering of local files doesn't need it.
|
|
const sandbox = process.platform === 'linux' ? ['--no-sandbox'] : [];
|
|
const browser = await puppeteer.launch({
|
|
executablePath: CHROME, headless: true, protocolTimeout: 0,
|
|
args: [...sandbox, '--allow-file-access-from-files', '--ignore-gpu-blocklist', ...gpu, '--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', timeout: 300000 });
|
|
await page.waitForFunction('window.ready === true', { timeout: 600000 });
|
|
if (args.loop) {
|
|
const ok = await page.evaluate(name => { if (!LOOPS[name]) return false; window.LOOP = LOOPS[name]; return true; }, args.loop);
|
|
if (!ok) { console.error(`no loop named "${args.loop}"`); process.exit(1); }
|
|
}
|
|
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');
|
|
};
|
|
// the length of whatever is being rendered: a loop's .len, or the video's duration
|
|
const lengthOf = page => page.evaluate(() => window.LOOP ? window.LOOP.len : DUR);
|
|
|
|
if (args.sheet || args.strip) {
|
|
const page = await openPage(), out = args.out || 'out/sheet.jpg'; mkdirSync(dirname(out), { recursive: true });
|
|
let ts;
|
|
if (args.strip) { const [a, b] = span(args.strip); ts = []; for (let i = Math.round(a * fps); i <= Math.round(b * fps); i++) ts.push(i / fps); }
|
|
else ts = times(args.sheet);
|
|
const crop = args.crop ? times(args.crop) : null, at = args['crop-at'] ? fields(args['crop-at']) : null;
|
|
const { url, ms } = await page.evaluate((ts, c, w, crop, at) => window.renderSheet(ts, c, w, crop, at), ts, +(args.cols || (args.strip ? 6 : 3)), +(args.w || (args.strip ? 320 : 640)), crop, at);
|
|
writeFileSync(out, Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'));
|
|
console.log(`${out} (${ts.length} frames) 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.png) {
|
|
// PNG sequence (for GIFs): a loop's full cycle (frame n equals frame 0, so it isn't rendered), or --range=a:b.
|
|
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
|
const [a, b] = args.range ? span(args.range) : [0, len], n = Math.round((b - a) * fps);
|
|
const out = args.out || `out/${args.loop ? 'loop_' + args.loop : 'png'}`, workers = +(args.workers || 3); mkdirSync(out, { recursive: true });
|
|
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}/f${String(i).padStart(4, '0')}.png`, await frameOf(page, a + i / fps, 'image/png')); }
|
|
}));
|
|
console.log(`${n} frames → ${out} (${((Date.now() - start) / n).toFixed(0)} ms/frame)`);
|
|
} else if (args.frames) {
|
|
// Parallel and resumable: each worker pulls the next missing frame; files are written atomically.
|
|
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
|
const [a, b] = args.range ? span(args.range) : [0, len], workers = +(args.workers || 4);
|
|
mkdirSync(FRAMES_DIR, { recursive: true });
|
|
const first = Math.round(a * fps), last = Math.min(Math.ceil(len * 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();
|
|
await Promise.all(Array.from({ length: workers }, 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`);
|
|
}
|
|
}
|
|
}));
|
|
} else if (args.clip) {
|
|
const page = await openPage(), len = await lengthOf(page);
|
|
const [a, b] = args.range ? span(args.range) : typeof args.clip === 'string' ? span(args.clip) : [0, len];
|
|
const audio = args.audio || await page.evaluate(() => PROJECT.audio || '');
|
|
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', '-',
|
|
...(audio ? ['-ss', String(a), '-t', String(b - a), '-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
|
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', 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', .93);
|
|
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}`);
|
|
} else {
|
|
console.log('nothing to do: see the usage notes at the top of render.mjs');
|
|
}
|
|
await browser.close();
|