105 lines
4.2 KiB
JavaScript
105 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Renders a deterministic canvas scene (window.render(t)) to PNG stills or
|
|
// a looping video piped straight into ffmpeg.
|
|
//
|
|
// NODE_PATH=<dir with playwright-core> node render.cjs \
|
|
// --page header.html --query "w=3840&h=1646&lang=zh" \
|
|
// --still 7.0 --out poster.png [--debug-safe]
|
|
// ... --video --fps 30 --out frames.mov (ProRes 4444 intermediate)
|
|
//
|
|
// Chromium comes from the Playwright cache; override with CHROMIUM_PATH.
|
|
|
|
const { chromium } = require('playwright-core');
|
|
const { spawn } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
function arg(name, fallback) {
|
|
const i = process.argv.indexOf(`--${name}`);
|
|
if (i === -1) return fallback;
|
|
const v = process.argv[i + 1];
|
|
return v === undefined || v.startsWith('--') ? true : v;
|
|
}
|
|
|
|
function chromiumPath() {
|
|
if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH;
|
|
const cache = path.join(os.homedir(), 'Library/Caches/ms-playwright');
|
|
const dirs = fs.readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort().reverse();
|
|
for (const d of dirs) {
|
|
const p = path.join(cache, d,
|
|
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
throw new Error('No Playwright Chromium found; set CHROMIUM_PATH');
|
|
}
|
|
|
|
(async () => {
|
|
const page = path.resolve(__dirname, arg('page', 'header.html'));
|
|
const query = arg('query', '');
|
|
const out = path.resolve(arg('out'));
|
|
const params = new URLSearchParams(query);
|
|
const W = +params.get('w');
|
|
const H = +params.get('h');
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath: chromiumPath(),
|
|
args: ['--force-color-profile=srgb', '--disable-gpu-vsync'],
|
|
});
|
|
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
|
|
const tab = await ctx.newPage();
|
|
tab.on('console', (m) => console.log('[page]', m.text()));
|
|
tab.on('pageerror', (e) => { console.error('[page error]', e); process.exit(1); });
|
|
await tab.goto(`file://${page}?${query}`);
|
|
await tab.evaluate(() => window.READY);
|
|
|
|
const grab = async (t, debugSafe) => {
|
|
const b64 = await tab.evaluate(async ({ t, debugSafe }) => {
|
|
await window.render(t);
|
|
if (debugSafe) window.SAFE_DEBUG();
|
|
const c = document.getElementById('c');
|
|
return c.toDataURL('image/png').split(',')[1];
|
|
}, { t, debugSafe });
|
|
return Buffer.from(b64, 'base64');
|
|
};
|
|
|
|
if (arg('layout')) {
|
|
const layout = await tab.evaluate(() => window.LAYOUT);
|
|
fs.writeFileSync(path.resolve(arg('layout')), JSON.stringify(layout, null, 2));
|
|
}
|
|
|
|
if (arg('still') !== undefined) {
|
|
const t = parseFloat(arg('still'));
|
|
fs.writeFileSync(out, await grab(t, !!arg('debug-safe')));
|
|
console.log(`wrote ${out}`);
|
|
} else if (arg('video')) {
|
|
const fps = +arg('fps', 30);
|
|
const duration = await tab.evaluate(() => window.DURATION);
|
|
const frames = Math.round(duration * fps);
|
|
// Lossless-ish intermediate; delivery encodes happen in a second pass.
|
|
const ff = spawn('ffmpeg', [
|
|
'-y', '-hide_banner', '-loglevel', 'error',
|
|
'-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-',
|
|
// Same matrix + tags as the simulator footage it gets composited with.
|
|
'-vf', 'scale=out_color_matrix=bt709:out_range=tv',
|
|
'-c:v', 'prores_ks', '-profile:v', '4', '-pix_fmt', 'yuva444p10le',
|
|
'-colorspace', 'bt709', '-color_primaries', 'bt709',
|
|
'-color_trc', 'iec61966-2-1', '-color_range', 'tv',
|
|
'-vendor', 'apl0', out,
|
|
], { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
const started = Date.now();
|
|
for (let i = 0; i < frames; i++) {
|
|
const buf = await grab(i / fps, false);
|
|
if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r));
|
|
if (i % 30 === 0) {
|
|
const s = ((Date.now() - started) / 1000).toFixed(0);
|
|
console.log(`frame ${i}/${frames} (${s}s)`);
|
|
}
|
|
}
|
|
ff.stdin.end();
|
|
await new Promise((r, j) => ff.on('close', (c) => (c === 0 ? r() : j(new Error(`ffmpeg ${c}`)))));
|
|
console.log(`wrote ${out}`);
|
|
}
|
|
await browser.close();
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|