#!/usr/bin/env node // Render a seek(t) HTML page to MP4 with Playwright + ffmpeg. // // node render.mjs --out video.mp4 [--fps 60] [--sub 4] [--workers 4] (defaults: VIDEO.fps, VIDEO.sub) // node render.mjs --stills qa/ [--at 1.5,3,4.2 | --every 0.5] // node render.mjs --out draft.mp4 --fps 30 --sub 1 --scale 0.5 --debug (fast draft) // node render.mjs --serve [--port 8080] preview in a browser (?t=12.5, ?debug=1) // --query "finish=0" pass options to the page's URL (any render or stills) // // The page must define window.VIDEO = {width, height, duration, cuts?} and window.seek(t); see // page.mjs for what the local server serves. --debug adds ?debug=1, which shows the frame // number, time and current cue on every frame (motion.js), for drafts only. // Motion blur: each output frame averages `sub` subframes spread across `shutter` of the frame // interval (ffmpeg tmix, in linear light), so 60fps x 4 = 240 page screenshots per second of video. // A loop renders frames 0..N-1 and never frame N, which would repeat frame 0 at the seam. // Work is split into time chunks rendered by parallel pages; chunks are joined // losslessly. window.SFX (if any) is written next to the output as .sfx.json. import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { serve, launch, openVideoPage, shot } from "./page.mjs"; const args = process.argv.slice(2); const opt = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; }; const page = path.resolve(args[0] ?? ""); if (!args[0] || !fs.existsSync(page)) { console.error("usage: render.mjs --out video.mp4 | --stills dir | --serve"); process.exit(1); } // fps and sub default to the page's VIDEO.fps and VIDEO.sub (a stepped style sets 24 and 1), else 60 and 4. let fps = Number(opt("fps", 0)); let sub = Number(opt("sub", 0)); const scale = Number(opt("scale", 1)); const workers = Number(opt("workers", process.env.RENDER_WORKERS || Math.max(2, Math.min(6, os.cpus().length - 2)))); // Fraction of the frame interval the subframes cover. 1 = 360-degree shutter (heavy blur; // fast camera moves ghost text into doubled copies). 0.4 = 144 degrees: smooth, still legible. const shutter = Number(opt("shutter", 0.4)); const out = opt("out"); const stills = opt("stills"); const from = Number(opt("from", 0)); // --query passes page options (for example finish=0 to render without the film finish); --debug adds debug=1. const query = [opt("query"), args.includes("--debug") ? "debug=1" : ""].filter(Boolean).join("&"); // --blur gamma averages subframes in gamma space (the older look); linear is the default. const linear = opt("blur", "linear") !== "gamma"; const { server, url } = await serve(page, Number(opt("port", 0))); if (args.includes("--serve")) { console.log(`serving ${url} (?t=12.5 freezes one moment, ?debug=1 shows frame, time and cue; Ctrl-C to stop)`); await new Promise(() => {}); } const browser = await launch(); const first = await openVideoPage(browser, url, { scale, query }); const { video } = first; const duration = Number(opt("to", video.duration)); fps ||= Number(video.fps) || 60; sub ||= Number(video.sub) || 4; const cuts = (video.cuts ?? []).map(Number).filter(Number.isFinite).sort((a, b) => a - b); // Selectors that style() could not find are usually typos ("title" for "#title"). const missing = await first.p.evaluate(async (d) => { for (const t of [0, d / 4, d / 2, (3 * d) / 4, d]) await window.seek(t); return [...(window.STYLE_MISSING || [])]; }, video.duration); if (missing.length) console.warn(`warning: style() selectors that match nothing: ${missing.join(", ")}`); if (stills) { fs.mkdirSync(stills, { recursive: true }); const every = Number(opt("every", 0)); let times = (opt("at") ?? "").split(",").filter(Boolean).map(Number); if (!times.length && every) for (let t = from; t < duration; t += every) times.push(+t.toFixed(3)); if (!times.length) times = video.stills ?? [0]; for (const t of times) { const buf = await shot(first.p, t); fs.writeFileSync(path.join(stills, `t${t.toFixed(2).padStart(7, "0")}.jpg`), buf); } console.log(`${times.length} stills -> ${stills}`); await browser.close(); server.close(); process.exit(0); } if (!out) { console.error("--out or --stills required"); process.exit(1); } const frames = Math.round((duration - from) * fps); // Split frames evenly; recount chunks so none is empty (15 frames over 6 workers is 5 chunks of 3). const per = Math.ceil(frames / Math.min(workers, frames)); const chunks = Math.ceil(frames / per); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "uimotion-")); process.on("exit", () => fs.rmSync(tmp, { recursive: true, force: true })); // also after a crash const started = Date.now(); let done = 0; async function renderChunk(index, ctx) { const k0 = index * per, k1 = Math.min(frames, k0 + per); const file = path.join(tmp, `c${String(index).padStart(3, "0")}.mp4`); // Subframes are averaged in linear light, as a film camera's shutter does: bright edges stay // bright instead of dulling to gray. 16-bit RGB keeps the dark tones exact. const toLinear = "lutrgb=r='pow(val/65535\\,2.2)*65535':g='pow(val/65535\\,2.2)*65535':b='pow(val/65535\\,2.2)*65535'"; const toGamma = "lutrgb=r='pow(val/65535\\,1/2.2)*65535':g='pow(val/65535\\,1/2.2)*65535':b='pow(val/65535\\,1/2.2)*65535'"; const select = `select='eq(mod(n\\,${sub})\\,${sub - 1})',setpts=N/(${fps}*TB)`; const blur = sub <= 1 ? "" : linear ? `format=gbrp16le,${toLinear},tmix=frames=${sub},${select},${toGamma},` : `tmix=frames=${sub},${select},`; // Screenshots are full-range JPEG. Convert to standard (limited) range, which every player, // including iPhones and Safari, decodes the same way. // H.264 needs even dimensions: a half-scale 1080x1350 page is 540x675, so round down to even. const filter = `${blur}scale=w=trunc(iw/2)*2:h=trunc(ih/2)*2:in_range=pc:out_range=tv,format=yuv420p`; const ff = spawn("ffmpeg", [ "-v", "error", "-y", "-f", "image2pipe", "-framerate", String(fps * sub), "-c:v", "mjpeg", "-i", "-", "-vf", filter, "-r", String(fps), "-c:v", "libx264", "-preset", "medium", "-crf", "15", "-color_range", "tv", "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709", file, ], { stdio: ["pipe", "inherit", "inherit"] }); const closed = new Promise((res, rej) => ff.on("close", (c) => (c ? rej(new Error(`ffmpeg ${c}`)) : res()))); for (let k = k0; k < k1; k++) { for (let j = 0; j < sub; j++) { // Subframes span `shutter` of the frame interval, ending at the frame time. A hard cut // (VIDEO.cuts) inside that span would blend the two shots, so subframes before it move // to the cut: the first frame of a shot is all new shot, as a camera cut is. const tFrame = from + k / fps; let t = Math.max(from, from + (k + ((j + 1 - sub) / sub) * shutter) / fps); for (const c of cuts) if (t < c && c <= tFrame + 1e-9) t = c; const buf = await shot(ctx.p, t); if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once("drain", r)); } done++; if (done % fps === 0) { const rate = done / ((Date.now() - started) / 1000); process.stdout.write(`\r${done}/${frames} frames ${rate.toFixed(1)} fps eta ${((frames - done) / rate).toFixed(0)}s `); } } ff.stdin.end(); await closed; return file; } const contexts = [first]; for (let i = 1; i < chunks; i++) contexts.push(await openVideoPage(browser, url, { scale, query })); const files = await Promise.all(contexts.map((c, i) => renderChunk(i, c))); await browser.close(); server.close(); const list = path.join(tmp, "list.txt"); fs.writeFileSync(list, files.map((f) => `file '${f}'\n`).join("")); fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); await new Promise((res, rej) => spawn("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", out], { stdio: "inherit" }).on("close", (c) => (c ? rej(new Error("concat")) : res()))); fs.writeFileSync(out.replace(/\.mp4$/, "") + ".sfx.json", JSON.stringify(first.sfx, null, 1)); fs.rmSync(tmp, { recursive: true, force: true }); console.log(`\n${out} ${frames} frames @ ${fps}fps x${sub} ${((Date.now() - started) / 1000).toFixed(0)}s`);