138a342c17
Correcting the pairing this script documents: the daylit frame goes on the light page and the night frame on the dark one. A reader in dark mode has asked for a dark room, and a bright noon render is a hole punched in it; the hairline frame around each figure is what keeps a night shot from dissolving into the page, which is the border's job rather than the sun's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
579 lines
25 KiB
JavaScript
579 lines
25 KiB
JavaScript
/**
|
|
* Photograph the engine, for the pages that describe it.
|
|
*
|
|
* node scripts/brand-assets/shots.mjs # everything, full size
|
|
* node scripts/brand-assets/shots.mjs --only fidi-night,office-floor
|
|
* node scripts/brand-assets/shots.mjs --preview # fast, 1x, PNGs to look at
|
|
* node scripts/brand-assets/shots.mjs --manifest-only # captions only, no browser
|
|
*
|
|
* `capture.mjs` next door makes the two share cards. This makes the product
|
|
* imagery: the pictures on lumbridgecorp.com/simulate that show what the thing
|
|
* looks like. Same harness, different framing — see `harness.mjs`.
|
|
*
|
|
* ### Why these are renders and not illustrations
|
|
*
|
|
* Every image this writes is a screenshot of the built `dist/` in this repo at
|
|
* the commit named in the manifest. Nothing is composited, retouched, or drawn.
|
|
* That is the whole point: /simulate/tera claims the sun is computed from your
|
|
* clock and the city lights come on as it sets, and the honest way to
|
|
* illustrate that claim is a photograph of it happening, taken by a script
|
|
* anyone can re-run. `og.png` on lumbridgecorp.com spent a month advertising a
|
|
* headline the site had stopped using, because it was a hand-made picture of a
|
|
* page rather than a render of one. This is that lesson applied to the art.
|
|
*
|
|
* ### The two outputs
|
|
*
|
|
* **The site.** `--site <dir>` (default: the sibling `lumbridge-v4` checkout)
|
|
* gets web-sized WebP into `apps/web/public/shots/` and a generated TypeScript
|
|
* manifest into `apps/web/src/data/shots.ts`. The captions travel *with* the
|
|
* images, in the manifest, so a page cannot show a caption for a picture that
|
|
* was reframed underneath it. If the directory is not there — which is the
|
|
* normal case for anyone who cloned this repo on its own — the step is skipped
|
|
* with a note, because the site is Lumbridge's consumer of this script and not
|
|
* a dependency of it.
|
|
*
|
|
* **The archive.** A dated folder under `shots/` keeps the full-resolution
|
|
* frame of every shot from every run. Nothing reads it. It is there because the
|
|
* cities keep changing and a render of the Bay from August is not recoverable
|
|
* once the heightfield moves — the manifest can be regenerated, the past
|
|
* cannot.
|
|
*/
|
|
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { mkdir, writeFile, access } from "node:fs/promises";
|
|
import { execFileSync } from "node:child_process";
|
|
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, "..", "..");
|
|
|
|
// ---- The shots --------------------------------------------------------------
|
|
|
|
/**
|
|
* `chapter` is an index into the chapter list, because that is all the DOM
|
|
* offers — `renderLegend()` in `main.ts` builds the buttons from the city pack's
|
|
* array and binds each to `flyToIndex(i)`, with no id on the element. `expect`
|
|
* is the `shortLabel` that index is supposed to be, asserted before the shutter
|
|
* opens. A city pack is data, and data gets reordered; without the assertion a
|
|
* reordered pack silently reframes every picture on the site and captions them
|
|
* with the old names.
|
|
*
|
|
* Every shot is taken **twice from the same camera**, once by day and once at
|
|
* night, and the site loads the one that matches the theme the reader is in —
|
|
* the daylit frame on the light page, the night frame on the dark one, so the
|
|
* picture is in the same light as the page around it. One camera and two clocks
|
|
* also happens to be the cheapest possible demonstration of the thing these
|
|
* pages claim hardest: the sun is computed from a real time, not painted on.
|
|
*
|
|
* Times are local to the city and are chosen, not defaulted. Midday is the
|
|
* worst light a heightfield ever gets — the sun is behind the camera, nothing
|
|
* casts, and 83k buildings read as gravel. The daylight frames are taken in the
|
|
* morning or late afternoon for the same reason a photographer would; the night
|
|
* frames sit around sixteen degrees below the horizon, which is dark enough for
|
|
* the window lights to carry the picture and not so dark that the ground has
|
|
* gone.
|
|
*/
|
|
const SHOTS = [
|
|
{
|
|
id: "bay-relief",
|
|
door: "tera",
|
|
city: "sf",
|
|
chapter: 0,
|
|
expect: "Whole Board",
|
|
place: "San Francisco Bay Area",
|
|
caption:
|
|
"Everything else in the engine stands on this heightfield: 83 named hills, four octaves of value noise, and overlapping peaks combined as tallest-plus-35%-of-the-rest — the one rule that makes a run of summits read as a ridge instead of stacking into a fictional mountain.",
|
|
day: {
|
|
at: "2026-08-06T07:40:00-07:00",
|
|
note: "Twenty to eight, sun fifteen degrees up: low enough that the hills rake shadow across the flats and the relief reads as relief.",
|
|
alt: "The San Francisco Bay Area seen from above in low morning light, the peninsula and the East Bay hills raking shadows across the water.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:35:00-07:00",
|
|
note: "Twenty-five to ten, and the same board is a scatter of lit ground between two dark ranges — the shape of the region is now where the cities are, not where the hills are.",
|
|
alt: "The same view of the Bay Area at night, the built-up ground picked out in clusters of light between dark hills and darker water.",
|
|
},
|
|
},
|
|
{
|
|
id: "soma",
|
|
door: "tera",
|
|
city: "sf",
|
|
chapter: 2,
|
|
expect: "SoMa",
|
|
place: "SoMa, San Francisco",
|
|
caption:
|
|
"South of Market, where the grid turns forty-six degrees and the blocks get long. About 83,000 buildings are on screen here, drawn as instanced geometry on street bearings the city pack declares per district.",
|
|
day: {
|
|
at: "2026-08-06T17:20:00-07:00",
|
|
note: "Twenty past five.",
|
|
alt: "Downtown San Francisco south of Market in afternoon light, towers clustered on a street grid turned at an angle to the rest of the city.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:35:00-07:00",
|
|
note: "Twenty-five to ten. Nothing about the geometry changed — only the hour the page was asked for.",
|
|
alt: "The same towers south of Market at night, windows lit in a scatter across every face.",
|
|
},
|
|
},
|
|
{
|
|
id: "fidi",
|
|
door: "tera",
|
|
city: "sf",
|
|
chapter: 3,
|
|
expect: "FiDi",
|
|
place: "Financial District, San Francisco",
|
|
caption:
|
|
"The old money and the venture money, stacked between Montgomery and the Embarcadero under the pyramid, with the Bay Bridge running out to the right.",
|
|
day: {
|
|
at: "2026-08-06T10:10:00-07:00",
|
|
note: "Ten past ten in the morning, sun forty-four degrees up.",
|
|
alt: "The San Francisco financial district from above in mid-morning light, the pyramid among the towers and the Bay Bridge crossing to the right.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:40:00-07:00",
|
|
note: "Twenty to ten, sun sixteen degrees below the horizon. The lights are not a night theme — they are emissive window faces chosen from a seeded RNG as the computed sun goes down. The moonlight carrying the ground is about five orders of magnitude brighter than the real moon-to-sun ratio, deliberately: the honest number renders a black rectangle.",
|
|
alt: "The San Francisco financial district at night, tower windows lit from within and the ground reading dark blue rather than black.",
|
|
},
|
|
},
|
|
{
|
|
id: "peninsula",
|
|
door: "tera",
|
|
city: "sf",
|
|
chapter: 6,
|
|
expect: "Peninsula",
|
|
place: "The Peninsula",
|
|
caption:
|
|
"Twenty miles of city between the water and the ridge, never more than four wide. The camera is about 32 km back — the city scene's orbit limit — which is why the region ships as five chapters rather than one impossible wide shot.",
|
|
day: {
|
|
at: "2026-08-06T09:20:00-07:00",
|
|
note: "Twenty past nine, with an aircraft on approach drawing the line across the bay.",
|
|
alt: "The San Francisco peninsula from above in morning light, the Santa Cruz mountains on one side, the bay on the other, and an aircraft trail crossing the water.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:35:00-07:00",
|
|
note: "Twenty-five to ten, and the ridge is legible only as the edge the lights stop at.",
|
|
alt: "The San Francisco peninsula at night, a band of lit ground between dark water and a darker mountain ridge.",
|
|
},
|
|
},
|
|
{
|
|
id: "socal-dtla",
|
|
door: "tera",
|
|
city: "socal",
|
|
chapter: 1,
|
|
expect: "DTLA",
|
|
place: "Downtown Los Angeles",
|
|
caption:
|
|
"The second city pack, on the same engine and the same renderer. A city is pure data — coastlines, hills, district street bearings, landmarks and camera chapters in one file with no code in it — so adding one is a reviewable contribution rather than a fork. The camera is square to downtown's grid, which is why everything else in the frame looks crooked.",
|
|
day: {
|
|
at: "2026-08-06T18:10:00-07:00",
|
|
note: "Ten past six, the San Gabriels standing behind the basin.",
|
|
alt: "Downtown Los Angeles from above in late afternoon light, the river running past the tower cluster and the San Gabriel mountains behind the basin.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:20:00-07:00",
|
|
note: "Twenty past nine. The basin is the one place on either board where the lit ground runs to the edge of the frame in every direction.",
|
|
alt: "Downtown Los Angeles at night, the tower cluster lit and the surrounding basin covered in light to the horizon.",
|
|
},
|
|
},
|
|
{
|
|
id: "office-floor",
|
|
door: "office",
|
|
chapter: 0,
|
|
expect: "The Floor",
|
|
place: "Lumbridge HQ",
|
|
caption:
|
|
"The same renderer from inside: thirty-four metres by eighteen, one storey, glazed along the north edge. An Office is one building's interior and a Space is a room within it — and this cannot share a THREE.Scene with the city, because San Francisco puts one scene unit at about 94 metres and this is one unit to the metre.",
|
|
day: {
|
|
at: "2026-08-06T11:20:00-07:00",
|
|
note: "Twenty past eleven in the morning.",
|
|
alt: "A whole office interior rendered in 3D as an open-topped model, walls, desks and meeting rooms laid out across one floor, with its plan drawn beside it.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:30:00-07:00",
|
|
note: "Half past nine. The office keeps its own light after the sun has gone, which is the difference between a room and a landscape.",
|
|
alt: "The same office model at night, lit from within against a dark ground.",
|
|
},
|
|
},
|
|
{
|
|
id: "office-desks",
|
|
door: "office",
|
|
chapter: 2,
|
|
expect: "Desks",
|
|
place: "Lumbridge HQ",
|
|
caption:
|
|
"Thirty-six seats in four benches. Stepping in here does not throw the city away — the city scene is retained and paused rather than disposed, because rebuilding the Bay's heightfield costs about 2.3 seconds and nobody should pay that to walk back out of a door.",
|
|
day: {
|
|
at: "2026-08-06T11:20:00-07:00",
|
|
note: "Twenty past eleven, daylight through the glazing on the north edge.",
|
|
alt: "A closer view inside the office: rows of desks with chairs and monitors, people seated at them, bookshelves and planting behind a partition.",
|
|
},
|
|
night: {
|
|
at: "2026-08-06T21:30:00-07:00",
|
|
note: "Half past nine, and the windows have stopped being the light source.",
|
|
alt: "The same desks at night, lit by the office's own lighting with darkness beyond the windows.",
|
|
},
|
|
},
|
|
];
|
|
|
|
/** The two frames every shot is taken in, in the order they are rendered. */
|
|
const VARIANTS = ["day", "night"];
|
|
|
|
// ---- Sizes ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Shot at 2x and delivered at two widths, because the pages that use these run
|
|
* a picture at about 720 CSS px on a desktop and full-bleed on a phone.
|
|
*
|
|
* WebP rather than PNG, and by a wide margin: these frames are photographs of a
|
|
* continuous-tone render, and PNG spends about 650 KB on what WebP says in 90.
|
|
* The encoder is the browser that is already open — this box has no `sharp`, no
|
|
* ImageMagick and no `rsvg-convert`, which is the same constraint that made the
|
|
* share cards a screenshot in the first place.
|
|
*/
|
|
const VIEWPORT = { width: 1440, height: 900 };
|
|
const SIZES = [
|
|
{ suffix: "1600", width: 1600, quality: 0.82 },
|
|
{ suffix: "800", width: 800, quality: 0.84 },
|
|
];
|
|
/** The archive frame: full sensor, near-lossless, kept forever. */
|
|
const ARCHIVE_QUALITY = 0.94;
|
|
|
|
// ---- 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 preview = process.argv.includes("--preview");
|
|
/**
|
|
* Rewrite the site's manifest from the shot list without opening a browser.
|
|
*
|
|
* A caption is prose and gets edited like prose — several times, in a row, to
|
|
* hear how it reads. Charging three minutes of software rasterisation for each
|
|
* pass is how a caption ends up shipped in the first wording anybody tried,
|
|
* which is the same failure the share-card README describes. The images this
|
|
* points at are the ones already on disk, and the commit it stamps is today's;
|
|
* both are true, because the pictures did not change.
|
|
*/
|
|
const manifestOnly = process.argv.includes("--manifest-only");
|
|
const only = flag("only")?.split(",").map((s) => s.trim());
|
|
const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4")));
|
|
const archiveRoot = resolve(flag("archive", join(ROOT, "shots")));
|
|
const wanted = only ? SHOTS.filter((s) => only.includes(s.id)) : SHOTS;
|
|
|
|
if (only) {
|
|
const unknown = only.filter((id) => !SHOTS.some((s) => s.id === id));
|
|
if (unknown.length) {
|
|
console.error(`unknown shot(s): ${unknown.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ---- Provenance -------------------------------------------------------------
|
|
|
|
const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
|
const commit = git("rev-parse", "--short", "HEAD");
|
|
/**
|
|
* Recorded, and shown on the site. A shot taken from a working tree with
|
|
* uncommitted changes is a picture of a build that exists on exactly one
|
|
* machine, and the manifest should say so rather than name a commit that would
|
|
* render something else.
|
|
*/
|
|
const dirty = git("status", "--porcelain").length > 0;
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
// ---- Capture ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Re-encode a PNG buffer to WebP at a target width, using the browser as the
|
|
* codec. `height` follows from the source aspect so a size table only has to
|
|
* carry widths.
|
|
*/
|
|
async function encode(page, png, width, quality) {
|
|
const dataUrl = await page.evaluate(
|
|
async ({ b64, w, q }) => {
|
|
const img = new Image();
|
|
img.src = `data:image/png;base64,${b64}`;
|
|
await img.decode();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = w;
|
|
canvas.height = Math.round((img.naturalHeight / img.naturalWidth) * w);
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = "high";
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
return canvas.toDataURL("image/webp", q);
|
|
},
|
|
{ b64: png.toString("base64"), w: width, q: quality },
|
|
);
|
|
return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
|
|
}
|
|
|
|
/**
|
|
* Read a chapter button's label, and optionally press it.
|
|
*
|
|
* The index is all the DOM offers, so `expect` is the guard. `main.ts` renders
|
|
* the number into its own span, hence the prefix strip.
|
|
*/
|
|
async function chapter(page, index, expect, { click = false } = {}) {
|
|
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: index, press: click },
|
|
);
|
|
if (label === null) throw new Error(`no chapter at index ${index}`);
|
|
if (label !== expect) {
|
|
throw new Error(
|
|
`chapter ${index} is "${label}", not "${expect}" — a city pack was reordered, ` +
|
|
`so the shot list and its captions need re-checking before anything ships`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function shoot(browser, shot, frame) {
|
|
const host = shot.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com";
|
|
const query = shot.city ? `?city=${shot.city}` : "";
|
|
const page = await browser.newPage({
|
|
viewport: VIEWPORT,
|
|
deviceScaleFactor: preview ? 1 : 2,
|
|
/**
|
|
* The camera cuts instead of flying, and this is the difference between a
|
|
* shot list that takes three minutes and one that takes twelve — or worse,
|
|
* one that quietly photographs the camera mid-flight.
|
|
*
|
|
* `scenekit.ts` eases a chapter change over about two seconds of *scene*
|
|
* time, and `stage.ts` clamps `dt` to 50 ms a frame. Under SwiftShader on a
|
|
* box with no GPU the app runs at roughly three frames a second, so those
|
|
* two seconds take about twenty of wall clock, and any fixed wait short of
|
|
* that lands somewhere over the bay. Reduced motion is the app's own
|
|
* documented answer to "I only clicked a name in a list": `flyTo` calls
|
|
* `setPose` and the pose is simply true on the next frame.
|
|
*/
|
|
reducedMotion: "reduce",
|
|
});
|
|
const problems = [];
|
|
page.on("pageerror", (e) => problems.push(String(e)));
|
|
try {
|
|
await page.addInitScript(clockShim(frame.at));
|
|
await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" });
|
|
/**
|
|
* Wait for the app to say it is up rather than for a number of seconds.
|
|
* The terrain is built in a worker and the city is ~83k instances, and how
|
|
* long that takes on software GL depends on the city, the machine and what
|
|
* else is running — a fixed sleep is either wrong or wasteful, and here it
|
|
* would have been both.
|
|
*/
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.getElementById("boot")?.hidden === true &&
|
|
document.querySelectorAll("#chapters .chapter").length > 0,
|
|
{ timeout: 180_000 },
|
|
);
|
|
// Boot hiding means the scene exists, not that it has drawn a full frame.
|
|
await page.waitForTimeout(preview ? 3000 : 6000);
|
|
await chapter(page, shot.chapter, shot.expect, { click: shot.chapter > 0 });
|
|
if (shot.chapter > 0) await page.waitForTimeout(2500);
|
|
await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER]);
|
|
const png = await page.screenshot({ timeout: 120_000, animations: "disabled" });
|
|
if (problems.length) {
|
|
throw new Error(`the page threw while being photographed: ${problems[0]}`);
|
|
}
|
|
return png;
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
// ---- Run --------------------------------------------------------------------
|
|
|
|
const exists = (p) => access(p).then(() => true, () => false);
|
|
const publicDir = join(siteDir, "apps", "web", "public", "shots");
|
|
const dataDir = join(siteDir, "apps", "web", "src", "data");
|
|
const haveSite = () => exists(join(siteDir, "apps", "web"));
|
|
const noSite = () =>
|
|
console.log(
|
|
`site skipped — no checkout at ${siteDir}\n` +
|
|
` (pass --site <dir>; the archive is complete either way)`,
|
|
);
|
|
|
|
if (manifestOnly) {
|
|
if (!(await haveSite())) {
|
|
noSite();
|
|
} else {
|
|
await mkdir(dataDir, { recursive: true });
|
|
await writeFile(join(dataDir, "shots.ts"), manifest());
|
|
console.log(`manifest → ${join(dataDir, "shots.ts")} (no images re-rendered)`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
const app = await serve(join(ROOT, "dist"), 5210, { spa: true });
|
|
const browser = await launch();
|
|
const results = [];
|
|
|
|
try {
|
|
// One page for encoding, reused. Spinning up a browser context per re-encode
|
|
// costs more than the encode does.
|
|
const codec = await browser.newPage();
|
|
await codec.goto("about:blank");
|
|
|
|
const archiveDir = join(archiveRoot, `${today}-${commit}${dirty ? "-dirty" : ""}`);
|
|
if (!preview) await mkdir(archiveDir, { recursive: true });
|
|
|
|
for (const shot of wanted) {
|
|
for (const variant of VARIANTS) {
|
|
const stem = `${shot.id}-${variant}`;
|
|
const png = await shoot(browser, shot, shot[variant]);
|
|
if (preview) {
|
|
await mkdir(join(ROOT, "shots", "preview"), { recursive: true });
|
|
await writeFile(join(ROOT, "shots", "preview", `${stem}.png`), png);
|
|
console.log("preview", stem);
|
|
continue;
|
|
}
|
|
|
|
await writeFile(
|
|
join(archiveDir, `${stem}.webp`),
|
|
await encode(codec, png, VIEWPORT.width * 2, ARCHIVE_QUALITY),
|
|
);
|
|
|
|
const sizes = {};
|
|
for (const size of SIZES) {
|
|
sizes[size.suffix] = await encode(codec, png, size.width, size.quality);
|
|
}
|
|
results.push({ stem, sizes });
|
|
console.log(
|
|
"shot ",
|
|
stem.padEnd(22),
|
|
SIZES.map((s) => `${s.suffix}w ${Math.round(sizes[s.suffix].length / 1024)}kB`).join(" "),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (preview) {
|
|
console.log(`\npreviews in ${join(ROOT, "shots", "preview")}`);
|
|
} else {
|
|
console.log(`\narchive ${archiveDir}`);
|
|
|
|
if (!(await haveSite())) {
|
|
noSite();
|
|
} else {
|
|
await mkdir(publicDir, { recursive: true });
|
|
await mkdir(dataDir, { recursive: true });
|
|
for (const { stem, sizes } of results) {
|
|
for (const size of SIZES) {
|
|
await writeFile(join(publicDir, `${stem}-${size.suffix}.webp`), sizes[size.suffix]);
|
|
}
|
|
}
|
|
// Regenerating the manifest from a partial run would drop every shot the
|
|
// run did not take, so `--only` writes images and leaves the manifest be.
|
|
if (only) {
|
|
console.log(`site ${results.length} frames → ${publicDir}`);
|
|
console.log(" manifest left alone (partial run; re-run without --only to rewrite it)");
|
|
} else {
|
|
await writeFile(join(dataDir, "shots.ts"), manifest());
|
|
console.log(`site ${results.length} frames → ${publicDir}`);
|
|
console.log(` manifest → ${join(dataDir, "shots.ts")}`);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
app.close();
|
|
}
|
|
|
|
// ---- The generated manifest -------------------------------------------------
|
|
|
|
function manifest() {
|
|
const aspect = VIEWPORT.width / VIEWPORT.height;
|
|
const frame = (id, variant) => {
|
|
const f = SHOTS.find((s) => s.id === id)[variant];
|
|
return `{
|
|
at: ${JSON.stringify(f.at)},
|
|
src: ${JSON.stringify(`/shots/${id}-${variant}-1600.webp`)},
|
|
srcSmall: ${JSON.stringify(`/shots/${id}-${variant}-800.webp`)},
|
|
alt: ${JSON.stringify(f.alt)},
|
|
note: ${JSON.stringify(f.note)},
|
|
}`;
|
|
};
|
|
const entries = SHOTS.map(
|
|
(s) => ` {
|
|
id: ${JSON.stringify(s.id)},
|
|
door: ${JSON.stringify(s.door)},
|
|
place: ${JSON.stringify(s.place)},
|
|
width: ${SIZES[0].width},
|
|
height: ${Math.round(SIZES[0].width / aspect)},
|
|
caption: ${JSON.stringify(s.caption)},
|
|
day: ${frame(s.id, "day")},
|
|
night: ${frame(s.id, "night")},
|
|
},`,
|
|
).join("\n");
|
|
|
|
return `/**
|
|
* Generated. Do not edit — \`scripts/brand-assets/shots.mjs\` in the tera repo
|
|
* rewrites this file wholesale, and the captions below live next to the camera
|
|
* poses that framed them so the two cannot drift apart.
|
|
*
|
|
* To change a picture or its caption: edit \`SHOTS\` in that script, run
|
|
* \`node scripts/brand-assets/shots.mjs\`, and commit both repos. A caption-only
|
|
* change can use \`--manifest-only\` and skip the render entirely.
|
|
*
|
|
* Every image is a screenshot of tera's built \`dist/\` at the commit named
|
|
* below. Nothing here is an illustration.
|
|
*/
|
|
|
|
/**
|
|
* A union rather than \`string\`, so a page asking for a picture that is not in
|
|
* this list fails \`npm run typecheck\` — which the pre-push hook runs — instead
|
|
* of rendering a hole a reader finds later.
|
|
*/
|
|
export type ShotId =
|
|
${SHOTS.map((s) => ` | ${JSON.stringify(s.id)}`).join("\n")};
|
|
|
|
/** One camera, one clock. Every shot has two of these and differs only in the hour. */
|
|
export interface Frame {
|
|
/** The instant the engine's own clock was set to. The sun follows from it. */
|
|
at: string;
|
|
src: string;
|
|
srcSmall: string;
|
|
alt: string;
|
|
/** What this hour in particular is showing, appended to the shot's caption. */
|
|
note: string;
|
|
}
|
|
|
|
export interface Shot {
|
|
id: ShotId;
|
|
/** Which door the engine was showing: the city, or an office interior. */
|
|
door: "tera" | "office";
|
|
/** Where this is, in words a reader would use. */
|
|
place: string;
|
|
width: number;
|
|
height: number;
|
|
/** True of both frames — the place and the mechanism, never the light. */
|
|
caption: string;
|
|
day: Frame;
|
|
night: Frame;
|
|
}
|
|
|
|
/** The tera commit these were rendered from. */
|
|
export const SHOTS_COMMIT = ${JSON.stringify(commit)};
|
|
/** True if that commit is not the whole story — the tree had uncommitted work. */
|
|
export const SHOTS_DIRTY = ${dirty};
|
|
/** The day the shutter opened, ISO. */
|
|
export const SHOTS_CAPTURED = ${JSON.stringify(today)};
|
|
|
|
export const SHOTS: Shot[] = [
|
|
${entries}
|
|
];
|
|
|
|
/** Total, because \`ShotId\` cannot name a shot that is not in \`SHOTS\`. */
|
|
export const byId = (id: ShotId): Shot => SHOTS.find((s) => s.id === id)!;
|
|
`;
|
|
}
|