diff --git a/.gitignore b/.gitignore index 0021589..f8cb41b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ scripts/brand-assets/art-*.png # straight into the site checkout and keeps the full-resolution frames here as a # dated archive; neither is source, and the archive is several MB a run. shots/ + +# Rendered films. `scripts/brand-assets/films.mjs` writes MP4s and posters here, +# plus a scratch frame directory it cleans up after itself. Regenerated, not source. +films/ diff --git a/scripts/brand-assets/films.mjs b/scripts/brand-assets/films.mjs new file mode 100644 index 0000000..d5568f6 --- /dev/null +++ b/scripts/brand-assets/films.mjs @@ -0,0 +1,310 @@ +/** + * Film the engine running, by stepping its clock rather than by recording it. + * + * node scripts/brand-assets/films.mjs # every film + * node scripts/brand-assets/films.mjs --only fidi-day + * node scripts/brand-assets/films.mjs --only fidi-day --frames 30 # a rough cut + * + * `shots.mjs` next door takes the stills. This takes the moving pictures, and + * the two share `harness.mjs` for the same reason they always did. + * + * ### Why this is not Remotion, or Motion Canvas, or HyperFrames + * + * All three of those compose an animation *out of code* — React components, + * canvas nodes, HTML and GSAP — and they are good at it. None of them can help + * here, because the animation already exists: it is a real-time 3D engine with + * a sun computed from a clock, and what is missing is not a way to author + * motion but a way to *record* motion that is already happening. That is a + * frame-stepper and an encoder, which is this file and ffmpeg. + * + * The distinction is worth holding on to, because the day a title card or a + * cross-fade between two of these films is wanted, one of those tools becomes + * exactly right — as an editor, downstream of footage this produced. Reaching + * for one now would mean reimplementing the city in React. + * + * ### Why the clock is stepped and not simply left running + * + * Real-time capture is not available on this box and would be the wrong idea + * anyway. SwiftShader draws about three frames a second with no GPU, so a + * screen recording would be a slideshow; and a sunset takes an hour, which is + * not a length of video anybody watches. So the film is rendered offline the + * way films always have been: set the clock, let the frame settle, expose, + * advance. The output is smooth 30 fps regardless of what the renderer managed + * while it was being photographed. + * + * Two shims in `filmClock()` make that possible from outside the app, without + * a reload per frame — a reload costs fifteen seconds and would put a 180-frame + * film at three quarters of an hour. + */ + +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { serve, launch, FURNITURE, hide } from "./harness.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); + +// ---- The films -------------------------------------------------------------- + +/** + * `chapter` and `expect` work exactly as they do in `shots.mjs` — an index into + * the chapter list, and the `shortLabel` it is asserted to be. + * + * `from` and `to` are the ends of the day the camera watches. They are not + * midnight to midnight: the hours either side of dawn and dusk are the whole + * point and the small hours are eighteen identical dark frames, so the window + * is cropped to the part that moves. + */ +const FILMS = [ + { + id: "fidi-day", + door: "tera", + city: "sf", + chapter: 3, + expect: "FiDi", + from: "2026-08-06T04:40:00-07:00", + to: "2026-08-06T22:40:00-07:00", + frames: 180, + fps: 30, + title: "Eighteen hours over the Financial District", + /** Which frame becomes the poster — the one worth stopping on. */ + poster: 0.86, + }, + { + id: "bay-relief-day", + door: "tera", + city: "sf", + chapter: 0, + expect: "Whole Board", + from: "2026-08-06T04:40:00-07:00", + to: "2026-08-06T22:40:00-07:00", + frames: 180, + fps: 30, + // The region shot is the one where the day does the most work. Shadow + // sweeps the length of two mountain ranges, and then the thing the relief + // was hiding turns up: the cities, which are somewhere else entirely. + title: "A day across the whole board", + poster: 0.2, + }, +]; + +const VIEWPORT = { width: 1440, height: 900 }; +/** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */ +const DELIVER_WIDTH = 1280; + +// ---- Arguments -------------------------------------------------------------- + +function flag(name, fallback = null) { + const i = process.argv.indexOf(`--${name}`); + return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--") + ? process.argv[i + 1] + : fallback; +} + +const only = flag("only")?.split(",").map((s) => s.trim()); +/** Override the frame count for a rough cut. A 30-frame pass takes about half a minute. */ +const frameOverride = flag("frames") ? Number(flag("frames")) : null; +const outRoot = resolve(flag("out", join(ROOT, "films"))); +const wanted = only ? FILMS.filter((f) => only.includes(f.id)) : FILMS; + +if (only) { + const unknown = only.filter((id) => !FILMS.some((f) => f.id === id)); + if (unknown.length) { + console.error(`unknown film(s): ${unknown.join(", ")}`); + process.exit(1); + } +} + +const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); +const commit = git("rev-parse", "--short", "HEAD"); +const dirty = git("status", "--porcelain").length > 0; +const today = new Date().toISOString().slice(0, 10); + +// ---- The two shims ---------------------------------------------------------- + +/** + * A clock the harness can move, and an app that notices when it does. + * + * **The skew is a live global, not a constant.** `shots.mjs` bakes one instant + * into the page at load, which is right for a still and useless here: 180 + * stills would be 180 page loads. Reading `__teraSkew` on every call means the + * whole page can be walked through a day without reloading. + * + * **`setInterval(…, 60_000)` is compressed to 120 ms.** This is the load-bearing + * half. `main.ts` recomputes the sun on a once-a-minute wall-clock tick — which + * is exactly right for a map somebody is looking at, and means a shifted clock + * would otherwise sit unrendered for up to a minute per frame. Only the 60-second + * interval is touched, by value, so nothing else in the app has its timing + * changed underneath it. + * + * Both are capture-harness lies told to the page, and neither is shipped: the + * deployed bundle has no idea this file exists. + */ +function filmClock() { + return `{ + globalThis.__teraSkew = 0; + const Real = Date; + globalThis.Date = class extends Real { + constructor(...a) { super(...(a.length ? a : [Real.now() + globalThis.__teraSkew])); } + static now() { return Real.now() + globalThis.__teraSkew; } + }; + const realSetInterval = globalThis.setInterval.bind(globalThis); + globalThis.setInterval = (fn, ms, ...rest) => + realSetInterval(fn, ms === 60000 ? 120 : ms, ...rest); +}`; +} + +/** Put the page's clock at `instant`, and wait until the frame showing it has been painted. */ +async function expose(page, instant) { + await page.evaluate((t) => { + globalThis.__teraSkew = t - performance.timeOrigin - performance.now(); + }, instant); + // Long enough for the compressed interval to fire and recompute the sun... + await page.waitForTimeout(260); + // ...and then two straddled frames, so what is on screen is what we just asked + // for rather than the one before it. A `waitForTimeout` alone cannot promise + // that at three frames a second. + await page.evaluate( + () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), + ); +} + +// ---- Filming ---------------------------------------------------------------- + +async function film(browser, spec, dir) { + const host = spec.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com"; + const query = spec.city ? `?city=${spec.city}` : ""; + const frames = frameOverride ?? spec.frames; + const page = await browser.newPage({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + // As in `shots.mjs`: the chapter cut has to be a cut, not a twenty-second + // flight the film would open in the middle of. + reducedMotion: "reduce", + }); + const problems = []; + page.on("pageerror", (e) => problems.push(String(e))); + + try { + await page.addInitScript(filmClock()); + await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" }); + await page.waitForFunction( + () => + document.getElementById("boot")?.hidden === true && + document.querySelectorAll("#chapters .chapter").length > 0, + { timeout: 180_000 }, + ); + await page.waitForTimeout(4000); + + const label = await page.evaluate( + ({ i, press }) => { + const button = [...document.querySelectorAll("#chapters .chapter")][i]; + if (!button) return null; + if (press) button.click(); + return button.textContent.replace(/^\d+/, "").trim(); + }, + { i: spec.chapter, press: spec.chapter > 0 }, + ); + if (label !== spec.expect) { + throw new Error(`chapter ${spec.chapter} is "${label}", not "${spec.expect}"`); + } + await page.waitForTimeout(2500); + + // The clock stays. It is the caption the film writes for itself, and the + // only thing on screen that proves the light is following a real time + // rather than a hand-keyed fade. + await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER]); + + const start = new Date(spec.from).getTime(); + const end = new Date(spec.to).getTime(); + const step = (end - start) / (frames - 1); + + for (let i = 0; i < frames; i++) { + await expose(page, start + step * i); + await page.screenshot({ + path: join(dir, `f-${String(i).padStart(4, "0")}.png`), + timeout: 120_000, + animations: "disabled", + }); + if (i % 20 === 0 || i === frames - 1) { + process.stdout.write(`\r frame ${i + 1}/${frames} `); + } + } + process.stdout.write("\n"); + if (problems.length) throw new Error(`the page threw while filming: ${problems[0]}`); + return frames; + } finally { + await page.close(); + } +} + +/** + * Frames to an MP4, plus the poster a `