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/shots.mjs
T
karti 2b2dd5cb64 chore(brand): re-shoot the cards, and stop describing a building that is gone
The share cards were stale in the two ways that survive a code fix, because both
of them end up baked into a picture.

**"Two buildings" was wrong in two files.** The words on the card live in
`og.html`; the words a crawler reads live in `vite.config.ts`. Fixing either one
alone leaves the other saying something else — and there are three packs now, one
of which is a courtyard block in Los Angeles that neither file mentioned. Both now
say three, name the LA block, and mention the microphone on the desk. Lumbridge HQ
also stopped being "a tower floor" some hours ago and is a live/work studio.

**The tera card was cropping around a defect that no longer exists.** Its framing
was pinned to `56% 88%` with a comment explaining that low was necessary to keep
the far edge of the board — where the terrain stopped and the sky began in a hard
diagonal — out of shot, because a card with that seam in it read as a rendering
fault rather than as a map. The sea now runs eighteen board spans and ends past the
fog's far plane, and the sky is a world-space dome, so there is no seam to hide
from. `56% 62%` puts the framing where the picture is best instead.

Also a per-shot `hide` in `shots.mjs`. The studio-hardware card is a
`.panel-group` inside a scrolling card, so when it is taller than the viewport the
last control is below the fold — correct on a screen, where a reader scrolls, and
wrong in a photograph, where the frame edge cuts a gain slider in half and anyone
who does not know the card scrolls reads it as a broken render. It is also a
question of subject: `office-desks` is a picture *of* that hardware and keeps it,
while the LA courtyard is a picture of a courtyard and does not want a column of
audio controls down one side.

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

673 lines
30 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-06T09:10:00-07:00",
note: "Ten past nine, sun thirty degrees up: high enough that the marine layer has burned back off the bay, low enough that the East Bay hills still rake shadow west across the flats.",
alt: "The San Francisco Bay Area seen from above in mid-morning light, the peninsula and the East Bay hills casting shadows west, scattered cloud over 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",
hide: ["#device-section"],
door: "office",
chapter: 1,
expect: "Studio",
place: "SF HQ · Studio",
caption:
"The same renderer from inside, at one unit to the metre — which is why this cannot share a THREE.Scene with the city, where San Francisco puts one unit at about 94. A live/work studio rather than a floorplate: the desk, the bench and the domestic half are one room, and an Office is one building's interior while a Space is a room within it.",
day: {
at: "2026-08-06T11:20:00-07:00",
note: "Twenty past eleven, daylight through the glazing — the sun is the real one over this coordinate, and the pack says where on the earth it stands and which way it is turned.",
alt: "A studio interior rendered in 3D as an open-topped model: desks with monitors, a sofa and rug, shelving and planting, with its floor plan drawn beside it.",
},
night: {
at: "2026-08-06T21:30:00-07:00",
note: "Half past nine. The building has switched its own lights on, because the sun it is following has gone down over the real San Francisco — and a fitting brightens as somebody walks under it.",
alt: "The same studio at night, lit from within by its ceiling fittings against a dark ground.",
},
},
{
id: "office-desks",
door: "office",
chapter: 2,
expect: "Agent Bench",
place: "SF HQ · Studio",
caption:
"The bench, and the hardware on it. A microphone on the desk and a speaker on the machine are declared by the pack and simulated deterministically behind an adapter a real device API can occupy later — power, gain, level, volume, all readable by anyone, all honestly labelled as demonstration data. 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.",
day: {
at: "2026-08-06T11:20:00-07:00",
note: "Twenty past eleven, daylight across the bench.",
alt: "A close view of a two-place desk bench in a studio: monitors, chairs, a desk microphone and a speaker, with planting behind.",
},
night: {
at: "2026-08-06T21:30:00-07:00",
note: "Half past nine, and the windows have stopped being the light source. The fittings have taken over.",
alt: "The same bench at night, lit by the studio's own lighting with darkness beyond the windows.",
},
},
{
id: "office-commons",
hide: ["#device-section"],
door: "office",
office: "LA HQ",
chapter: 6,
expect: "Courtyard",
place: "LA HQ · Office, Arts District",
caption:
"A second studio, eight hundred kilometres south, and the argument for the format being a format. A courtyard block square to the 1781 pueblo grid rather than to the compass — which is the whole reason a pack carries a heading, because the heading decides which walls the sun comes through. Same engine, same clock, same sun; nothing here was ever a mesh file.",
day: {
at: "2026-08-06T11:20:00-07:00",
note: "Twenty past eleven, and the courtyard is taking the sun the way a courtyard at 36 degrees off north does.",
alt: "An open courtyard inside a two-storey block rendered in 3D: planters, a parasol, tables and chairs, glazed offices around the edges.",
},
night: {
at: "2026-08-06T21:30:00-07:00",
note: "Half past nine, and the courtyard is lit from the rooms around it rather than from above.",
alt: "The same courtyard at night, lit from the glazed rooms around its edges.",
},
},
{
id: "office-hangar",
hide: ["#device-section"],
door: "office",
office: "Frontier Valley",
chapter: 0,
expect: "Hangar",
place: "Frontier Valley, Alameda Point",
caption:
"A third pack, still in development, and the clearest statement of what an office pack is: one room, fifty-four metres by thirty, nine to the trusses, in a hangar on the old naval air station across the estuary. It feels nothing like either studio because four metres above an airfield is not a hundred and eighty-eight above Transbay, and the pack is what says so.",
day: {
at: "2026-08-06T11:20:00-07:00",
note: "Twenty past eleven. North light through the clerestory, which is the light a shed actually wants.",
alt: "A large open hangar interior rendered in 3D: benches of desks down the middle, freestanding meeting boxes at one end, a mezzanine deck at the other, and a twelve-metre door.",
},
night: {
at: "2026-08-06T21:30:00-07:00",
note: "Half past nine, and the trusses are carrying the lights.",
alt: "The same hangar at night, lit from the roof trusses.",
},
},
];
/** 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 a full render 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 — so a flight is about
* forty frames however fast they are drawn, and any fixed wait shorter than
* they take lands somewhere over the bay. Under the software fallback that
* was twenty seconds. 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,
undefined,
{ timeout: 180_000 },
);
// Boot hiding means the scene exists, not that it has drawn a full frame.
await page.waitForTimeout(preview ? 3000 : 6000);
/**
* Which building, before which view of it.
*
* The office door opens on the first entry in `OFFICES` and there are two
* of them now, so a shot of the hangar has to press the picker first — and
* has to do it *before* choosing a chapter, because switching buildings
* disposes the scene and rebuilds it, which would strand a chapter click
* against a legend that no longer exists.
*
* Selected by visible label rather than by index, and asserted, for the same
* reason `chapter()` asserts its `shortLabel`: a reordered `OFFICES` table
* would otherwise silently photograph the wrong building and caption it with
* the other one's name.
*/
if (shot.office) {
const button = page.locator("#cities button", { hasText: shot.office });
const count = await button.count();
if (count === 0) {
throw new Error(
`no office picker button matching "${shot.office}" — the OFFICES table ` +
`changed, so the shot list and its captions need re-checking`,
);
}
await button.first().click();
// A building switch tears the scene down and builds another. Wait on the
// app again rather than on a clock, exactly as the first boot does.
await page.waitForFunction(
() =>
document.getElementById("boot")?.hidden === true &&
document.querySelectorAll("#chapters .chapter").length > 0,
undefined,
{ timeout: 180_000 },
);
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);
/*
* `shot.hide` is the per-frame escape hatch, and the studio-hardware card is
* why it exists.
*
* The device section is a `.panel-group` inside a scrolling card, so when the
* panel is taller than the viewport the card scrolls and the last control is
* simply below the fold. That is correct on a screen — a reader scrolls — and
* wrong in a photograph, where the frame edge slices a gain slider in half and
* every viewer who does not know the card scrolls reads it as a broken render.
*
* It is also a question of subject. `office-desks` is a picture *of* the desk
* mic and the machine speaker and wants the card open beside them. The
* courtyard is a picture of a courtyard, and a column of audio controls down
* one side of it is answering a question nobody asked.
*/
await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER, ...(shot.hide ?? [])]);
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)!;
`;
}