Eric's direction: passing through an ordinary warp mouth is a normal stride. The veil already marks the doorway, and the wizard walks through it as through any door — no shimmer, no surge, and the eyes carry on the way they were going instead of turning back to show the mouth. The surge stays for the moment a wormhole is cast, staged straight from the warpOpened event through both its new mouths. The heist's pinned frames re-blessed for it: at the crossing the reel now looks down the corridor at the enemy's home rather than back through the mouth. deploy/film-clips.mjs films the catalog for the gallery — every scene in both takes, the reel playing under the paused clock at one frame per 40ms, muxed to 25fps mp4s with the first pinned beat as the poster. The recorder used for the first clip set lived in a session scratchpad and was lost; this one lives here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
121 lines
6.1 KiB
JavaScript
121 lines
6.1 KiB
JavaScript
// 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 <slug>-fpv.mp4, <slug>-board.mp4,
|
|
// <slug>.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 <out-dir> [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 <out-dir> [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 });
|
|
}
|