// Film the screenplay catalog for the public gallery: every scene in two // takes — through the pov wizard's eyes and from the board — as the reel // plays it under a paused Playwright clock, one screenshot per 40ms of // simulated time, muxed by ffmpeg into 25fps mp4s. The poster is the // first pinned beat, settled. Writes -fpv.mp4, -board.mp4, // .jpg and clips.json into the output dir — then run // clips-prep.mjs and clips-publish.sh on it. // // npx tsx deploy/film-clips.mjs [scene ...] // // Run from the repo root AFTER `npx vite build` in packages/web. Needs // ffmpeg and the Playwright chromium cache (see verify-scenes.mjs). import { execFileSync, spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts"; const [outDir, ...only] = process.argv.slice(2); if (!outDir) { console.error("usage: film-clips.mjs [scene ...]"); process.exit(1); } const PORT = 8809; const FPS = 25; const STEP_MS = 1500; // the reel's own pace at 1x const TAIL_MS = 2500; // hold on the last move const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); function chromiumExe() { if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); const builds = existsSync(cache) ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() : []; for (const build of builds.reverse()) { const exe = join(cache, build, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); if (existsSync(exe)) return exe; } throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); } const dataDir = mkdtempSync(join(tmpdir(), "wizwar-film-")); const server = spawn("npx", ["tsx", "src/index.ts"], { cwd: join(import.meta.dirname, "..", "packages", "server"), env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") }, stdio: "ignore", }); const up = async () => { for (let i = 0; i < 60; i++) { try { if ((await fetch(`http://127.0.0.1:${PORT}/`)).ok) return; } catch { /* not yet */ } await new Promise((r) => setTimeout(r, 500)); } throw new Error("server never came up"); }; try { await up(); mkdirSync(outDir, { recursive: true }); const browser = await chromium.launch({ executablePath: chromiumExe(), args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"], }); const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name)); if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`); const catalog = []; for (const sp of scenes) { let seconds = 0; for (const view of ["fpv", "board"]) { const frames = mkdtempSync(join(tmpdir(), `wizwar-frames-${sp.name}-${view}-`)); const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); await page.clock.install({ time: EPOCH }); await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" }); await page.locator("button", { hasText: "❚❚" }).click(); const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]); for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click(); if (view === "fpv") await page.getByText("your eyes").click(); await page.clock.pauseAt(EPOCH + 60_000); await page.clock.runFor(400); // let the first frame settle before rolling const box = await page.locator(".replay").boundingBox(); const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }; // Roll: the reel's play button is the ▶ before the step button. await page.locator("button", { hasText: "▶" }).first().click(); const total = (count - 1) * STEP_MS + TAIL_MS; const nFrames = Math.ceil(total / (1000 / FPS)); const posterAt = ((sp.beats?.[0] ?? 1) * STEP_MS + 1200) / (1000 / FPS); for (let f = 0; f < nFrames; f++) { const png = await page.screenshot({ clip }); writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png); if (view === "fpv" && f === Math.round(posterAt)) writeFileSync(join(frames, "poster.png"), png); await page.clock.runFor(1000 / FPS); } await ctx.close(); execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-framerate", String(FPS), "-i", join(frames, "%05d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "22", "-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`)]); if (view === "fpv") { const poster = existsSync(join(frames, "poster.png")) ? join(frames, "poster.png") : join(frames, "00000.png"); execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`)]); seconds = Math.round(total / 1000); } rmSync(frames, { recursive: true, force: true }); console.log(`${sp.name} ${view}: ${nFrames} frames`); } catalog.push({ name: sp.name, title: sp.title, blurb: sp.blurb, seconds }); } await browser.close(); // A partial run keeps the rest of an existing catalog. const file = join(outDir, "clips.json"); const prior = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : []; const merged = SCREENPLAYS.map((sp) => catalog.find((c) => c.name === sp.name) ?? prior.find((c) => c.name === sp.name)).filter(Boolean); writeFileSync(file, JSON.stringify(merged, null, 2) + "\n"); console.log(`filmed ${catalog.length} scenes into ${outDir}`); } finally { server.kill(); rmSync(dataDir, { recursive: true, force: true }); }