#!/usr/bin/env node // Frame-by-frame renderer for a paused-GSAP ad page. // // The page must expose window.DURATION (seconds), window.seek(t) and // window.ready (a promise that resolves once fonts and images are in). // // node render.cjs --stills 0.4,3.2,6.6 still-.jpg per time // node render.cjs --out ad-16x9.mp4 [--web] [--poster 9.6] // node render.cjs --format 4x5 --out ad-4x5.mp4 1080×1350 cut // // Options: --page index.html --format 16x9|4x5 --size WxH --fps 30 // --crf 18 --web (also writes -web.mp4 at crf 25) // --poster (writes -poster.jpg) --channel chrome|chromium const { chromium } = require("playwright-core"); const { spawn } = require("child_process"); const path = require("path"); const ffmpeg = require("ffmpeg-static"); const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); if (i === -1) return d; const v = process.argv[i + 1]; return v === undefined || v.startsWith("--") ? true : v; }; const format = arg("format", "16x9"); const [W, H] = String(arg("size", format === "4x5" ? "1080x1350" : "1920x1080")).split("x").map(Number); const fps = Number(arg("fps", 30)); const page = path.resolve(arg("page", "index.html")); function run(bin, args) { return new Promise((resolve, reject) => { const p = spawn(bin, args, { stdio: ["ignore", "inherit", "pipe"] }); let err = ""; p.stderr.on("data", (d) => (err += d)); p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err.slice(-2000))))); }); } (async () => { const browser = await chromium.launch({ channel: arg("channel", "chrome") }); const p = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); p.on("pageerror", (e) => console.error("pageerror:", e.message)); await p.goto(`file://${page}?render&format=${format}`, { waitUntil: "load" }); await p.evaluate(() => window.ready); const duration = await p.evaluate(() => window.DURATION); const stills = arg("stills"); if (stills) { for (const t of String(stills).split(",").map(Number)) { await p.evaluate((t) => window.seek(t), t); await p.screenshot({ path: `still-${t.toFixed(2)}.jpg`, type: "jpeg", quality: 90 }); } console.log(`wrote ${String(stills).split(",").length} stills`); return browser.close(); } const out = String(arg("out", `ad-${format}.mp4`)); const ff = spawn(ffmpeg, ["-y", "-f", "image2pipe", "-framerate", String(fps), "-c:v", "png", "-i", "-", "-c:v", "libx264", "-preset", "slow", "-crf", String(arg("crf", 18)), "-pix_fmt", "yuv420p", "-profile:v", "high", "-movflags", "+faststart", "-r", String(fps), out], { stdio: ["pipe", "inherit", "pipe"] }); let ffErr = ""; ff.stderr.on("data", (d) => (ffErr += d)); const frames = Math.round(duration * fps); const started = Date.now(); for (let i = 0; i < frames; i++) { await p.evaluate((t) => window.seek(t), i / fps); const buf = await p.screenshot({ type: "png" }); if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once("drain", r)); if (i % 90 === 0) console.log(`frame ${i}/${frames} ${((Date.now() - started) / 1000).toFixed(0)}s`); } ff.stdin.end(); const code = await new Promise((r) => ff.on("close", r)); await browser.close(); if (code !== 0) throw new Error(ffErr.slice(-2000)); console.log(`wrote ${out} (${frames} frames, ${W}×${H})`); const base = out.replace(/\.mp4$/, ""); if (arg("web")) { // Smaller encode for web pages: visually close, roughly half the bytes. await run(ffmpeg, ["-v", "error", "-y", "-i", out, "-c:v", "libx264", "-preset", "slow", "-crf", "25", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", `${base}-web.mp4`]); console.log(`wrote ${base}-web.mp4`); } const poster = arg("poster"); if (poster) { await run(ffmpeg, ["-v", "error", "-y", "-ss", String(poster), "-i", out, "-frames:v", "1", "-q:v", "2", `${base}-poster.jpg`]); console.log(`wrote ${base}-poster.jpg`); } })().catch((e) => { console.error(e.message || e); process.exit(1); });