1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/scripts/brand-assets/films.mjs
T
karti 68a5165d6f The hero gets its own film, shot bare and encoded for a page that autoplays it
`hero-soma` is the landing backdrop: SoMa, midnight to midnight, 180 frames at
eight minutes each so the loop closes without repeating a minute. It is the only
film shot `chrome: "bare"` — the figures keep the app's panel because its clock
is their caption, but a hero has a headline on top and a second interface under
that is two interfaces arguing. It is also the only one that runs a full 24
hours, because the page seeks it to the reader's local hour and a film starting
at 04:40 has no frame for somebody at two in the morning.

Whole-board was the first camera and was wrong: at that standoff the edge of the
terrain plate stands against the sky across the top of the frame, and midday is
a lot of pale sand.

Encoding is two-stage now — frames to a CRF 18 master, deliverables derived from
it. A time-lapse is near the worst case for inter-frame compression, because the
camera never moves but every pixel changes as the light does, and at the figure
default of CRF 22 this reel came out at 2.5 MB. Deriving from a master turned
'try CRF 28' into ten seconds instead of a twenty-two minute re-shoot, and it
keeps what ships identical to what this script makes rather than something
hand-rolled with ffmpeg the day the size became a problem. It lands at 1440w
CRF 28 (1.0 MB) plus a 960w CRF 30 (402 kB) for small screens, because <video>
has no srcset and a phone has no use for the wide encode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:12:54 -07:00

610 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 24 # a rough cut
* node scripts/brand-assets/films.mjs --manifest-only # captions only
* node scripts/brand-assets/films.mjs --publish films/2026-08-06-abc1234
*
* `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, access, copyFile } 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 --------------------------------------------------------------
const VIEWPORT = { width: 1440, height: 900 };
/** A hero is a band, not a window, so it is framed wider than the figures. */
const HERO_VIEWPORT = { width: 1600, height: 900 };
/** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */
const DELIVER_WIDTH = 1280;
const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.width);
/**
* What each film ships as.
*
* A figure is one file at 1280. The hero ships **two**, because it autoplays
* above the fold and a phone at 390 CSS px has no use for a 1440-wide encode —
* it is the same picture at two and a half times the bytes, on the connection
* least able to spare them.
*
* The CRFs are measured, not guessed. A time-lapse is close to the worst case
* for inter-frame compression: the camera never moves but every pixel changes
* every frame as the light does, so the encoder has no static background to
* lean on. At CRF 22 the hero came out at 2.5 MB. Behind a scrim that is 3896%
* opaque, CRF 28 is indistinguishable and 987 kB.
*/
const DELIVERABLES = {
figure: [{ suffix: "", width: DELIVER_WIDTH, crf: 22 }],
hero: [
{ suffix: "", width: 1440, crf: 28 },
{ suffix: "-sm", width: 960, crf: 30 },
],
};
const deliverablesFor = (spec) => (spec.chrome === "bare" ? DELIVERABLES.hero : DELIVERABLES.figure);
const deliverSize = (spec) => {
const vp = spec.viewport ?? VIEWPORT;
const w = deliverablesFor(spec)[0].width;
return { w, h: Math.round((w * vp.height) / vp.width) };
};
/**
* `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,
place: "Financial District, San Francisco",
caption:
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky and the window lights all follow from that one number. The clock in the corner is the film captioning itself.",
alt: "A time-lapse of the San Francisco financial district seen from above. Shadow sweeps across the towers as the sun crosses the sky, the water changes colour, and after sunset the tower windows light up one by one.",
},
{
/**
* The hero on lumbridgecorp.com, and the only film shot `bare`.
*
* The figures keep the app's panel because the clock in it is the caption.
* A hero has its own headline sitting on top of the picture, and a second
* column of interface underneath that headline is not atmosphere, it is
* two interfaces arguing.
*
* The camera is SoMa rather than the whole board, which was tried first: at
* that standoff the edge of the terrain plate is visible against the sky
* across the top of the frame, and midday is a lot of pale sand. Close in,
* the city fills the frame to every edge at every hour.
*
* It is also the only one that runs **midnight to midnight**. The others
* crop to the part of the day that moves; this one cannot, because the page
* seeks it to the reader's own local hour and a film that starts at 04:40
* has no frame to show someone at two in the morning. 180 frames across 24
* hours is exactly eight minutes a frame, and the last frame lands at 23:52
* rather than midnight so the loop closes without showing the same minute
* twice.
*/
id: "hero-soma",
door: "tera",
city: "sf",
chapter: 2,
expect: "SoMa",
from: "2026-08-06T00:00:00-07:00",
to: "2026-08-06T23:52:00-07:00",
frames: 180,
fps: 30,
viewport: HERO_VIEWPORT,
chrome: "bare",
/** Whole-day, so this is the only film where the poster hour is a choice about light. */
poster: 0.33,
title: "San Francisco, one whole day",
place: "SoMa, San Francisco",
caption:
"A full day of San Francisco every six seconds, starting at whatever hour it is where you are. The sun, the moon and the window lights are computed from a clock rather than themed — the one claim this engine makes that a picture can settle on its own.",
alt: "A time-lapse of downtown San Francisco from above, running through a whole day: dark before dawn, long shadows at sunrise, flat midday light over the tower cluster and the Bay Bridge, then dusk and the windows lighting up one by one.",
},
{
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,
place: "San Francisco Bay Area",
caption:
"Relief is easiest to read when the light moves. Shadow runs the length of two mountain ranges and back, and then at the end the thing the terrain never told you appears on its own: the cities, which are somewhere else entirely.",
alt: "A time-lapse of the whole San Francisco Bay Area from above. Long shadows rake across the hills as the sun crosses, and after dark the built-up ground emerges as scattered clusters of light between the ranges.",
},
];
// ---- 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.
*
* Filming costs about seven seconds a frame at 1440x900 on this box, so a
* 180-frame film is some twenty-two minutes. `--frames 24` is three, and it is
* the cheapest possible way to discover that a camera is pointed at the wrong
* thing. A rough cut also skips the site, because a 24-frame stutter is not
* something to publish by accident.
*/
const frameOverride = flag("frames") ? Number(flag("frames")) : null;
const outRoot = resolve(flag("out", join(ROOT, "films")));
const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4")));
/** Rewrite the site's manifest from the film list without filming anything. */
const manifestOnly = process.argv.includes("--manifest-only");
/**
* Publish an existing render directory to the site instead of shooting a new one.
*
* Filming is twenty-two minutes a reel, so there has to be a way to put reels
* that already exist in front of the site without paying that again — otherwise
* the answer to "how did those files get there" becomes `cp`, and a hand-copied
* artefact is exactly the thing this pipeline exists to not have.
*/
const publishFrom = flag("publish");
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: spec.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);
// For a figure the clock stays: it is the caption the film writes for
// itself, and the only thing on screen proving the light follows a real
// time rather than a hand-keyed fade. A `bare` film is going behind
// somebody else's headline and takes all of it off.
await hide(page, [
...FURNITURE.transient,
...(spec.chrome === "bare" ? FURNITURE.BARE : 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 `<video>` shows before anybody presses
* play.
*
* `yuv420p` and `+faststart` are not decoration: without the first, Safari and
* a good deal of Android will not decode the file at all, and without the
* second the index sits at the end and the video will not begin until the whole
* thing has arrived.
*/
/**
* Frames to a master, the master to deliverables, and a poster off the frames.
*
* Two stages rather than one, deliberately. The master is a near-lossless
* archive encode that stays in the render directory; every shipped file is
* derived from it. That makes "the hero is too heavy, try CRF 28" a ten-second
* job instead of a twenty-two-minute re-shoot, and — the part that matters —
* it means what is on the site is always exactly what this script produces,
* rather than something hand-rolled with ffmpeg the day the size became a
* problem.
*
* `yuv420p` and `+faststart` are not decoration: without the first, Safari and
* a good deal of Android will not decode the file at all, and without the
* second the index sits at the end and playback waits for the whole download.
*
* The poster comes off the PNG, not off the master, because it is a still and
* has no reason to inherit a video codec's compromises.
*/
function encode(dir, out, fps, frames, posterAt, deliverables) {
const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
const master = `${out}-master.mp4`;
ff(
"-framerate", String(fps),
"-i", join(dir, "f-%04d.png"),
"-c:v", "libx264",
"-preset", "slow",
"-crf", "18",
"-pix_fmt", "yuv420p",
master,
);
for (const d of deliverables) {
ff(
"-i", master,
"-vf", `scale=${d.width}:-2:flags=lanczos`,
"-c:v", "libx264",
"-preset", "slow",
"-crf", String(d.crf),
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
`${out}${d.suffix}.mp4`,
);
}
const posterFrame = Math.min(frames - 1, Math.round((frames - 1) * posterAt));
ff(
"-i", join(dir, `f-${String(posterFrame).padStart(4, "0")}.png`),
"-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`,
"-quality", "82",
`${out}-poster.webp`,
);
return posterFrame;
}
// ---- Run --------------------------------------------------------------------
const exists = (p) => access(p).then(() => true, () => false);
const publicDir = join(siteDir, "apps", "web", "public", "films");
const dataDir = join(siteDir, "apps", "web", "src", "data");
const haveSite = () => exists(join(siteDir, "apps", "web"));
async function writeManifest() {
await mkdir(dataDir, { recursive: true });
await writeFile(join(dataDir, "films.ts"), manifest());
return join(dataDir, "films.ts");
}
/**
* Copy a render directory to the site, then rewrite the manifest.
*
* A film that is not in `fromDir` but is already published is **left alone**
* rather than treated as an error. Reels cost twenty-two minutes each, so a
* one-reel render is the normal way to add or replace one, and demanding that
* every reel be present in the same directory would mean re-shooting the whole
* set to change any of it. What is not tolerated is a film that exists in
* neither place: the manifest is about to name it, so that is a hard failure
* rather than a broken `<video>` discovered later.
*/
async function publish(fromDir) {
await mkdir(publicDir, { recursive: true });
let copied = 0;
const kept = [];
for (const spec of FILMS) {
const names = [
...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`),
`${spec.id}-poster.webp`,
];
if (await exists(join(fromDir, names[0]))) {
for (const name of names) await copyFile(join(fromDir, name), join(publicDir, name));
copied += 1;
continue;
}
if (await exists(join(publicDir, names[0]))) {
kept.push(spec.id);
continue;
}
throw new Error(
`${spec.id} is neither in ${fromDir} nor already published — film it before the ` +
`manifest names it`,
);
}
console.log(`site ${copied} film(s) copied → ${publicDir}`);
if (kept.length) console.log(` kept already-published: ${kept.join(", ")}`);
console.log(` manifest → ${await writeManifest()}`);
}
if (manifestOnly || publishFrom) {
if (!(await haveSite())) {
console.log(`site skipped — no checkout at ${siteDir}`);
} else if (publishFrom) {
await publish(resolve(publishFrom));
} else {
console.log(`manifest → ${await writeManifest()} (nothing re-filmed)`);
}
process.exit(0);
}
const app = await serve(join(ROOT, "dist"), 5210, { spa: true });
const browser = await launch();
try {
const outDir = join(outRoot, `${today}-${commit}${dirty ? "-dirty" : ""}`);
await mkdir(outDir, { recursive: true });
const made = [];
for (const spec of wanted) {
console.log(`film ${spec.id}${spec.title}`);
const framesDir = join(outRoot, ".frames", spec.id);
await rm(framesDir, { recursive: true, force: true });
await mkdir(framesDir, { recursive: true });
const shot = await film(browser, spec, framesDir);
const out = join(outDir, spec.id);
const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster, deliverablesFor(spec));
await rm(framesDir, { recursive: true, force: true });
made.push({ spec, out });
const seconds = (shot / spec.fps).toFixed(1);
console.log(` ${shot} frames → ${seconds}s at ${spec.fps}fps, poster from frame ${posterFrame}`);
console.log(` ${out}.mp4`);
}
await writeFile(
join(outDir, "README.txt"),
`Rendered from tera ${commit}${dirty ? " (working tree had uncommitted changes)" : ""} on ${today}.\n` +
`Every frame is the running dist/ with its own clock stepped; nothing is keyframed.\n` +
`Regenerate: node scripts/brand-assets/films.mjs\n`,
);
console.log(`\nout ${outDir}`);
if (!(await haveSite())) {
console.log(`site skipped — no checkout at ${siteDir} (pass --site <dir>)`);
} else if (frameOverride) {
// A rough cut is for looking at, not for publishing.
console.log(`site skipped — this was a ${frameOverride}-frame rough cut`);
} else {
await mkdir(publicDir, { recursive: true });
for (const { spec, out } of made) {
for (const d of deliverablesFor(spec)) {
await copyFile(`${out}${d.suffix}.mp4`, join(publicDir, `${spec.id}${d.suffix}.mp4`));
}
await copyFile(`${out}-poster.webp`, join(publicDir, `${spec.id}-poster.webp`));
}
console.log(`site ${made.length} film(s) → ${publicDir}`);
// As in `shots.mjs`: a partial run must not rewrite a manifest that would
// then name films this run did not make.
if (only) {
console.log(" manifest left alone (partial run; re-run without --only)");
} else {
console.log(` manifest → ${await writeManifest()}`);
}
}
} finally {
await browser.close();
app.close();
}
// ---- The generated manifest -------------------------------------------------
function manifest() {
const entries = FILMS.map(
(f) => ` {
id: ${JSON.stringify(f.id)},
place: ${JSON.stringify(f.place)},
title: ${JSON.stringify(f.title)},
src: ${JSON.stringify(`/films/${f.id}.mp4`)},${
deliverablesFor(f).some((d) => d.suffix === "-sm")
? `\n srcSmall: ${JSON.stringify(`/films/${f.id}-sm.mp4`)},`
: ""
}
poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)},
width: ${deliverSize(f).w},
height: ${deliverSize(f).h},
seconds: ${Number((f.frames / f.fps).toFixed(2))},
from: ${JSON.stringify(f.from)},
to: ${JSON.stringify(f.to)},
caption: ${JSON.stringify(f.caption)},
alt: ${JSON.stringify(f.alt)},
},`,
).join("\n");
return `/**
* Generated. Do not edit — \`scripts/brand-assets/films.mjs\` in the tera repo
* rewrites this file wholesale.
*
* To change a film or its caption: edit \`FILMS\` in that script and run it.
* A caption-only change can use \`--manifest-only\` and skip the ~22 minutes a
* film costs to shoot.
*
* Every frame of every film below is a screenshot of tera's built \`dist/\` at
* the commit named here, with the app's own clock stepped between frames.
* Nothing is keyframed and nothing is an illustration.
*/
/** A union rather than \`string\`, so a page naming a film that is gone fails typecheck. */
export type FilmId =
${FILMS.map((f) => ` | ${JSON.stringify(f.id)}`).join("\n")};
export interface Film {
id: FilmId;
/** Where this is, in words a reader would use. */
place: string;
/** One line, for the figure's heading. */
title: string;
src: string;
/** A narrower encode for small screens, where one exists. Same film, fewer bytes. */
srcSmall?: string;
poster: string;
width: number;
height: number;
/** Running time. Used to decide whether a pause control is required; it is. */
seconds: number;
/** The ends of the day the camera watched, ISO. */
from: string;
to: string;
caption: string;
/** Describes what happens, not just what is shown — it stands in for the film. */
alt: string;
}
/** The tera commit these were filmed from. */
export const FILMS_COMMIT = ${JSON.stringify(commit)};
/** True if that commit is not the whole story — the tree had uncommitted work. */
export const FILMS_DIRTY = ${dirty};
/** The day the camera rolled, ISO. */
export const FILMS_CAPTURED = ${JSON.stringify(today)};
export const FILMS: Film[] = [
${entries}
];
/** Total, because \`FilmId\` cannot name a film that is not in \`FILMS\`. */
export const filmById = (id: FilmId): Film => FILMS.find((f) => f.id === id)!;
`;
}