1
0

Films reach the site the same way stills do

`films.mjs` now writes into the sibling lumbridge-v4 checkout — the MP4s and
their posters into `apps/web/public/films/`, and a generated `films.ts` beside
the shots manifest, carrying each reel's caption, alt text and running time.
Same shape as `shots.mjs`, including the `FilmId` union that turns a page
naming a deleted reel into a typecheck failure rather than a dead <video>.

`--publish <dir>` puts an existing render in front of the site without
re-shooting it. Twenty-two minutes a reel is long enough that the alternative
would have been `cp`, and a hand-copied artefact is the thing this pipeline
exists to not have. `--frames` refuses to publish at all: a 24-frame stutter is
a rough cut, not something to put on a website by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 18:01:11 -07:00
parent b4eb2fa1a8
commit ff23f5d4bd
+165 -3
View File
@@ -3,7 +3,9 @@
*
* 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
* 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.
@@ -39,7 +41,7 @@
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { mkdir, writeFile, rm, access, copyFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { serve, launch, FURNITURE, hide } from "./harness.mjs";
@@ -71,6 +73,10 @@ const FILMS = [
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.",
},
{
id: "bay-relief-day",
@@ -87,12 +93,17 @@ const FILMS = [
// 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.",
},
];
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;
const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.width);
// ---- Arguments --------------------------------------------------------------
@@ -104,9 +115,29 @@ function flag(name, fallback = null) {
}
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. */
/**
* 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) {
@@ -274,12 +305,48 @@ function encode(dir, out, fps, frames, posterAt) {
// ---- 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");
}
async function publish(fromDir) {
await mkdir(publicDir, { recursive: true });
for (const spec of FILMS) {
for (const name of [`${spec.id}.mp4`, `${spec.id}-poster.webp`]) {
const src = join(fromDir, name);
if (!(await exists(src))) throw new Error(`${src} is not there — film it first`);
await copyFile(src, join(publicDir, name));
}
}
console.log(`site ${FILMS.length} film(s) → ${publicDir}`);
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}`);
@@ -291,6 +358,7 @@ try {
const out = join(outDir, spec.id);
const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster);
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}`);
@@ -304,7 +372,101 @@ try {
`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) {
await copyFile(`${out}.mp4`, join(publicDir, `${spec.id}.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`)},
poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)},
width: ${DELIVER_WIDTH},
height: ${DELIVER_HEIGHT},
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;
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)!;
`;
}