A day of Eric watching the clips as a newcomer would. The camera can pitch: FirstPerson takes a `pitch` that shears the horizon up the pane (walls center on it, the eye stays half a wall high), so the ground at the pane's foot comes within half a cell. The director uses it for deeds at your own feet — a conjuration on your square, a treasure taken up, the pit you fell down — instead of the cutaway that stepped outside your body. Everything shows through a warp. The floor pass now runs between the cast and the wall draw, so a column that bent through a mouth maps its ground through the warp's rigid motion and wears the far room's decals — the pit past the wormhole included — under the same violet haze as its walls. A wizard down a pit is public (inPit on the player view), drawn sunk to the floor line in first person and dimmed on the board. A body sharing your square, which had no depth to draw at, stands right in front of you. A pit leap glides like a shove instead of cutting. And the director's blocked-sight test no longer counts a warp BEYOND the target as a block, nor stands a cutaway camera inside another wizard's square — the pair that put Wanderer's face across the whole pane. The reel stops for a die: a card over the held world names who rolls and for what, tumbles the faces on the reel's own clock, lands the roll, and only then plays the outcome; the beat, the fx, the glides and the camera all wait it out. A punch by you drives a fist into the scene; one at you comes at the camera; both land with a comic POW. film-clips rolls until the reel's last move rather than a fixed count, and the gate settles longer on a step whose caption rolls a die. All thirty goldens re-blessed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
130 lines
6.6 KiB
JavaScript
130 lines
6.6 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 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();
|
|
// Roll until the reel reaches its last move (steps a die decides
|
|
// run longer than the beat), then hold the tail. The poster is
|
|
// the first pinned beat, settled.
|
|
const posterBeat = sp.beats?.[0] ?? 1;
|
|
let f = 0, tail = -1, posterIn = -1, total = 0;
|
|
const MAX_FRAMES = FPS * 240; // a reel that never ends is a bug, not a feature
|
|
while (tail !== 0 && f < MAX_FRAMES) {
|
|
const png = await page.screenshot({ clip });
|
|
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
|
|
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
|
|
if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(1200 / (1000 / FPS));
|
|
if (posterIn === 0) writeFileSync(join(frames, "poster.png"), png);
|
|
if (posterIn >= 0) posterIn--;
|
|
if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS));
|
|
if (tail > 0) tail--;
|
|
await page.clock.runFor(1000 / FPS);
|
|
f++; total += 1000 / FPS;
|
|
}
|
|
const nFrames = f;
|
|
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 });
|
|
}
|