3c4649d078
Three things, all found by trying to re-shoot the product imagery and failing.
**`crypto.randomUUID` is secure-context only.** `main.ts` called it at module top
level for its three wire identities, so on any origin that is not HTTPS and not
`localhost` the call threw before the scene was built and the app stopped at
"Starting up" with one TypeError and no other symptom. Every way a developer
normally opens this app is a secure context — `vite dev` and `vite preview` serve
localhost, the deployed site is HTTPS — which is why this survived since 3326d2e.
It breaks the brand-capture harness, which serves `dist/` over
`http://tera.lumbridgecorp.com:5210` so the app can read its own hostname and
decide which door it is; it breaks the plain `http://` static host STATIC.md
explicitly invites; and it breaks opening the dev server by LAN IP to try it on a
phone. `src/ids.ts` prefers the platform's `randomUUID` and falls back to
`getRandomValues`, which carries no such restriction. The tests exercise the
fallback specifically, because the happy path was never the broken one.
**`waitForFunction` was ignoring its own timeout.** Playwright's signature is
`(pageFunction, arg, options)` and all four call sites in `shots.mjs` and
`films.mjs` passed `{ timeout: 180_000 }` second, binding it as the predicate's
argument. The wait silently used the 30 s default, which was invisible for as
long as the app booted inside thirty seconds and started failing the moment the
California board grew its relief — with "Timeout 30000ms exceeded" reported
against a line that plainly reads 180_000.
**The office shot list photographed a building that no longer exists.** The
`expect` guard caught it and refused to shoot, which is exactly what it is for:
chapter 0 is "Front Door" now, not "The Floor". But the captions were staler than
the labels — they described forty-eight metres by eighteen, thirty-six seats in
four benches and a fourteen-metre interstitial commons, and Lumbridge HQ is a
live/work studio now. Re-aimed at the buildings that exist: `office-floor` and
`office-desks` at the SF studio (the second specifically at the bench, because the
desk mic and the machine speaker are the new thing there), `office-commons` at the
LA courtyard, `office-hangar` still at Frontier Valley and now honest about being
in development. The four ids are deliberately unchanged: the manifest emits a
`ShotId` union that v4 imports, so renaming one fails v4's typecheck at push.
Also `#onboarding-host` joins the harness's clutter list. The first-run card is
correct behaviour for a real visitor and wrong in a product photograph, and it had
quietly placed itself in the middle of every frame — which is the general hazard
that list exists for, because a shot with a stray card still renders and still
looks deliberate.
`bay-relief`'s daylight frame moves from 07:40 to 09:10. At 07:40 the marine layer
buried the heightfield the shot exists to demonstrate; the version currently on the
site is almost entirely white. Its `note` moves with it, since the note names the
hour and a stale one describes light that is not in the picture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
655 lines
29 KiB
JavaScript
655 lines
29 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",
|
|
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",
|
|
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",
|
|
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);
|
|
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)!;
|
|
`;
|
|
}
|