Repository navigation
Expand file tree
/
Copy pathrender.mjs
More file actions
103 lines (98 loc) · 6.61 KB
/
Copy pathrender.mjs
File metadata and controls
103 lines (98 loc) · 6.61 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
// 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/job_replacement.mp4] frames + song → MP4
// node render.mjs --eval='JSON.stringify(stationState(0, 21.5))' evaluate an expression in the studio page
// add --test=<name> to render a test scene from src/tests.js instead of the video
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.length ? v.join('=') : true]; }));
const CHROME = args.chrome || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'C:/Program Files/Google/Chrome/Application/chrome.exe');
const DUR = 169.2, fps = +(args.fps || 30), SONG = 'assets/music.wav';
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/job_replacement.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', SONG,
'-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', '--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' + (args.test ? '&test=' + args.test : ''), { waitUntil: 'networkidle0' });
await page.waitForFunction('window.ready === true', { timeout: 60000 });
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.eval) {
const page = await openPage();
console.log(await page.evaluate(String(args.eval)));
} else 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 });
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.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', SONG,
'-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();