The engine gets photographed, and the camera stops moving while the shutter is open
`shots.mjs` renders the product imagery for lumbridgecorp.com/simulate: seven frames of the running `dist/`, at chapters and times chosen rather than defaulted, written as WebP into the sibling site checkout together with a generated manifest that carries each picture's caption and alt text. The pages there had described a renderer in prose for their whole life without ever showing one, which is a strange way to sell a renderer. The captions live next to the camera poses in this repo, not on the site, so re-aiming a camera cannot leave a caption behind describing the old view. The manifest emits a `ShotId` union, so a page asking for a picture that has been renamed fails the site's typecheck instead of rendering a hole. Along the way: the share cards were never reproducible. `scenekit.ts` eases a chapter change over about two seconds of scene time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader here draws about three frames a second — so the flight takes twenty seconds of wall clock and `capture.mjs` waited four. Every run caught the camera at a different point over the bay, and none of them at the chapter the key press asked for. Both scripts now open the page with reduced motion, which is the app's own answer to "somebody clicked a name in a list": `flyTo` sets the pose outright. `og-tera.png` is regenerated and is Hayes Valley for the first time. The bytes still differ run to run, because aircraft are crossing and cloud shadow is drifting; the framing no longer does. `harness.mjs` is the static server, the SwiftShader flags, the two-hostnames- one-dist trick and the clock shim, extracted because there are two consumers now and two copies would have drifted apart while both claimed to photograph the same app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,3 +30,8 @@ docs/
|
|||||||
# `scripts/brand-assets/capture.mjs`; the finished cards in `public/` are what
|
# `scripts/brand-assets/capture.mjs`; the finished cards in `public/` are what
|
||||||
# ships, and these are 2.8 MB of screenshot nobody needs in history.
|
# ships, and these are 2.8 MB of screenshot nobody needs in history.
|
||||||
scripts/brand-assets/art-*.png
|
scripts/brand-assets/art-*.png
|
||||||
|
|
||||||
|
# Rendered shots. `scripts/brand-assets/shots.mjs` writes the web-sized copies
|
||||||
|
# straight into the site checkout and keeps the full-resolution frames here as a
|
||||||
|
# dated archive; neither is source, and the archive is several MB a run.
|
||||||
|
shots/
|
||||||
|
|||||||
Generated
+48
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.3.6"
|
"vite": "^7.3.6"
|
||||||
}
|
}
|
||||||
@@ -1528,6 +1529,53 @@
|
|||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.25",
|
"version": "8.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@lumbridge/tera",
|
"name": "@lumbridge/tera",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Tera \u2014 the map view of Lumbridge Simulate. Cities from above; with Spaces, offices you can walk into.",
|
"description": "Tera — the map view of Lumbridge Simulate. Cities from above; with Spaces, offices you can walk into.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"workspaces": ["server"],
|
"workspaces": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.3.6"
|
"vite": "^7.3.6"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 551 KiB After Width: | Height: | Size: 646 KiB |
@@ -23,12 +23,9 @@
|
|||||||
* kept true by running that command.
|
* kept true by running that command.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { createServer } from "node:http";
|
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { extname } from "node:path";
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = join(HERE, "..", "..");
|
const ROOT = join(HERE, "..", "..");
|
||||||
@@ -45,64 +42,36 @@ const PUBLIC = join(ROOT, "public");
|
|||||||
*/
|
*/
|
||||||
const NOON = "2026-08-06T12:40:00-07:00";
|
const NOON = "2026-08-06T12:40:00-07:00";
|
||||||
|
|
||||||
const MIME = {
|
|
||||||
".html": "text/html; charset=utf-8",
|
|
||||||
".js": "text/javascript",
|
|
||||||
".css": "text/css",
|
|
||||||
".png": "image/png",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".webmanifest": "application/manifest+json",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A static server over one directory, with the SPA fallback the app expects. */
|
|
||||||
function serve(dir, port, { spa = false } = {}) {
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
||||||
for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) {
|
|
||||||
if (!candidate) continue;
|
|
||||||
try {
|
|
||||||
const body = await readFile(join(dir, candidate));
|
|
||||||
res.writeHead(200, { "content-type": MIME[extname(candidate)] ?? "application/octet-stream" });
|
|
||||||
res.end(body);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
/* try the next candidate */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.writeHead(404).end("not found");
|
|
||||||
});
|
|
||||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHROME_ARGS = [
|
|
||||||
"--no-sandbox",
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
// Software GL, so this runs on a box with no display and no GPU.
|
|
||||||
"--use-gl=angle",
|
|
||||||
"--use-angle=swiftshader",
|
|
||||||
// The app reads its own hostname to decide which door it is. Resolving both
|
|
||||||
// names at the local server is what makes one `dist/` produce both shots.
|
|
||||||
"--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1",
|
|
||||||
];
|
|
||||||
|
|
||||||
const clockShim = `{
|
|
||||||
const target = new Date(${JSON.stringify(NOON)}).getTime();
|
|
||||||
const skew = target - Date.now();
|
|
||||||
const Real = Date;
|
|
||||||
globalThis.Date = class extends Real {
|
|
||||||
constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); }
|
|
||||||
static now() { return Real.now() + skew; }
|
|
||||||
};
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) {
|
async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) {
|
||||||
// 2x, so the art is still sharp when a timeline shows the card at 600px wide
|
// 2x, so the art is still sharp when a timeline shows the card at 600px wide
|
||||||
// on a retina screen.
|
// on a retina screen.
|
||||||
const page = await browser.newPage({
|
const page = await browser.newPage({
|
||||||
viewport: { width: 1400, height: 900 },
|
viewport: { width: 1400, height: 900 },
|
||||||
deviceScaleFactor: 2,
|
deviceScaleFactor: 2,
|
||||||
|
/**
|
||||||
|
* Without this the card was framed differently every run, and had been
|
||||||
|
* since the day it was written.
|
||||||
|
*
|
||||||
|
* `scenekit.ts` eases a chapter change over about two seconds of scene
|
||||||
|
* time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader on this
|
||||||
|
* box draws about three frames a second — so the flight below takes some
|
||||||
|
* twenty seconds of wall clock, and the four this waited caught the camera
|
||||||
|
* partway across the bay at a different point each time. Two consecutive
|
||||||
|
* captures of an unchanged repo produced two different cards, neither of
|
||||||
|
* them the chapter the key press had asked for.
|
||||||
|
*
|
||||||
|
* Reduced motion is the app's own answer for "somebody clicked a name in a
|
||||||
|
* list": `flyTo` sets the pose outright, so the shutter opens on the pose
|
||||||
|
* the key names.
|
||||||
|
*
|
||||||
|
* The *framing* is what this fixes, not the bytes. Aircraft are still
|
||||||
|
* crossing and cloud shadow is still drifting, so two runs differ by a few
|
||||||
|
* pixels and `git diff` will always show the PNG as changed. That is the
|
||||||
|
* app being alive, and worth far less than the framing being on purpose.
|
||||||
|
*/
|
||||||
|
reducedMotion: "reduce",
|
||||||
});
|
});
|
||||||
await page.addInitScript(clockShim);
|
await page.addInitScript(clockShim(NOON));
|
||||||
await page.goto(url, { waitUntil: "networkidle" });
|
await page.goto(url, { waitUntil: "networkidle" });
|
||||||
await page.waitForTimeout(settle);
|
await page.waitForTimeout(settle);
|
||||||
if (key) {
|
if (key) {
|
||||||
@@ -111,13 +80,9 @@ async function shootApp(browser, url, file, { key = null, settle = 20000 } = {})
|
|||||||
await page.waitForTimeout(4000);
|
await page.waitForTimeout(4000);
|
||||||
}
|
}
|
||||||
// The chrome comes off. The card supplies its own typography, and the app's
|
// The chrome comes off. The card supplies its own typography, and the app's
|
||||||
// panels shrunk to card size are unreadable furniture.
|
// panels shrunk to card size are unreadable furniture. A product screenshot
|
||||||
await page.evaluate(() => {
|
// wants the opposite — see `shots.mjs`, which keeps the instruments.
|
||||||
for (const sel of ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#boot", "#scrim"]) {
|
await hide(page, [...FURNITURE.transient, ...FURNITURE.BARE]);
|
||||||
document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
|
await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
|
||||||
console.log("art ", file);
|
console.log("art ", file);
|
||||||
await page.close();
|
await page.close();
|
||||||
@@ -146,7 +111,7 @@ const cardsOnly = process.argv.includes("--cards-only");
|
|||||||
|
|
||||||
const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
|
const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
|
||||||
const assets = await serve(HERE, 8799);
|
const assets = await serve(HERE, 8799);
|
||||||
const browser = await chromium.launch({ channel: "chrome", args: CHROME_ARGS });
|
const browser = await launch();
|
||||||
try {
|
try {
|
||||||
if (!cardsOnly) {
|
if (!cardsOnly) {
|
||||||
await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png");
|
await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png");
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* The bits of "photograph the running app" that both `capture.mjs` and
|
||||||
|
* `shots.mjs` need.
|
||||||
|
*
|
||||||
|
* This file exists because the second consumer arrived. `capture.mjs` shot the
|
||||||
|
* app to make two share cards; `shots.mjs` shoots it to make the product
|
||||||
|
* imagery on lumbridgecorp.com. Everything below — the static server, the
|
||||||
|
* SwiftShader flags, the two-hostnames-one-dist trick, the clock shim — was
|
||||||
|
* already load-bearing for the first one, and copying it would have meant two
|
||||||
|
* capture pipelines drifting apart while both claimed to photograph the same
|
||||||
|
* app. There is one pipeline. The two scripts differ only in what they frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join, extname } from "node:path";
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".json": "application/json",
|
||||||
|
".webmanifest": "application/manifest+json",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A static server over one directory, with the SPA fallback the app expects. */
|
||||||
|
export function serve(dir, port, { spa = false } = {}) {
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||||
|
for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) {
|
||||||
|
if (!candidate) continue;
|
||||||
|
try {
|
||||||
|
const body = await readFile(join(dir, candidate));
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": MIME[extname(candidate)] ?? "application/octet-stream",
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* try the next candidate */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(404).end("not found");
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHROME_ARGS = [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
// Software GL, so this runs on a box with no display and no GPU.
|
||||||
|
"--use-gl=angle",
|
||||||
|
"--use-angle=swiftshader",
|
||||||
|
// The app reads its own hostname to decide which door it is. Resolving both
|
||||||
|
// names at the local server is what makes one `dist/` produce both shots.
|
||||||
|
"--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function launch() {
|
||||||
|
return chromium.launch({ channel: "chrome", args: CHROME_ARGS });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the page's clock to a fixed instant.
|
||||||
|
*
|
||||||
|
* The sun is real — `solar.ts` computes it from `new Date()` — so a shot taken
|
||||||
|
* at two in the morning is an honest photograph of a black rectangle. The clock
|
||||||
|
* is *shifted* rather than frozen because the app drives everything else off
|
||||||
|
* `requestAnimationFrame`, and a stopped clock stalls the frame loop the
|
||||||
|
* screenshot is waiting on.
|
||||||
|
*
|
||||||
|
* This is also the only time control available here. Scrubbing the date from
|
||||||
|
* the godmode panel needs `can.debug`, which needs a signed-in admin; shifting
|
||||||
|
* `Date` needs nothing, and produces the same sun.
|
||||||
|
*/
|
||||||
|
export function clockShim(iso) {
|
||||||
|
return `{
|
||||||
|
const target = new Date(${JSON.stringify(iso)}).getTime();
|
||||||
|
const skew = target - Date.now();
|
||||||
|
const Real = Date;
|
||||||
|
globalThis.Date = class extends Real {
|
||||||
|
constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); }
|
||||||
|
static now() { return Real.now() + skew; }
|
||||||
|
};
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a shot leaves out, by kind of shot.
|
||||||
|
*
|
||||||
|
* `BARE` is the share card's answer: the card supplies its own typography, and
|
||||||
|
* the app's panels shrunk to 1200×630 are unreadable furniture.
|
||||||
|
*
|
||||||
|
* `CLUTTER` is the product screenshot's, and it is deliberately much shorter.
|
||||||
|
* The left panel and the plan view are not chrome over the product, they *are*
|
||||||
|
* the product; a picture of the engine with its instruments cropped off is a
|
||||||
|
* stock photo of a landscape. What goes is only what is untrue outside the
|
||||||
|
* capture, or unreadable inside it:
|
||||||
|
*
|
||||||
|
* - `#tier` reads "Full view" here because the capture runs against a local
|
||||||
|
* `dist/` with no API to say otherwise. On the deployed site an anonymous
|
||||||
|
* visitor gets "Public view". Shipping the local answer as marketing art
|
||||||
|
* would be a picture of a permission nobody browsing the site actually has.
|
||||||
|
* - `#rail` is the keyboard hint strip, which is advice for a person holding a
|
||||||
|
* keyboard, not information about the city.
|
||||||
|
*/
|
||||||
|
export const FURNITURE = {
|
||||||
|
/** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */
|
||||||
|
transient: ["#boot", "#scrim"],
|
||||||
|
BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier"],
|
||||||
|
CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hide selectors, and wait a beat for the layout to settle before shooting. */
|
||||||
|
export async function hide(page, selectors) {
|
||||||
|
await page.evaluate((sels) => {
|
||||||
|
for (const sel of sels) {
|
||||||
|
document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
|
||||||
|
}
|
||||||
|
}, selectors);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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 relief shots are taken in the
|
||||||
|
* morning and the city shots at dusk for the same reason a photographer would.
|
||||||
|
*/
|
||||||
|
const SHOTS = [
|
||||||
|
{
|
||||||
|
id: "bay-relief",
|
||||||
|
door: "tera",
|
||||||
|
city: "sf",
|
||||||
|
chapter: 0,
|
||||||
|
expect: "Whole Board",
|
||||||
|
at: "2026-08-06T07:40:00-07:00",
|
||||||
|
place: "San Francisco Bay Area",
|
||||||
|
caption:
|
||||||
|
"The whole region at twenty to eight in the morning, sun fifteen degrees up. 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.",
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "soma-afternoon",
|
||||||
|
door: "tera",
|
||||||
|
city: "sf",
|
||||||
|
chapter: 2,
|
||||||
|
expect: "SoMa",
|
||||||
|
at: "2026-08-06T17:20:00-07:00",
|
||||||
|
place: "SoMa, San Francisco",
|
||||||
|
caption:
|
||||||
|
"South of Market at twenty past five, 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.",
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fidi-night",
|
||||||
|
door: "tera",
|
||||||
|
city: "sf",
|
||||||
|
chapter: 3,
|
||||||
|
expect: "FiDi",
|
||||||
|
at: "2026-08-06T21:40:00-07:00",
|
||||||
|
place: "Financial District, San Francisco",
|
||||||
|
caption:
|
||||||
|
"The same city at 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, the Bay Bridge running off to the right and the ground reading dark blue rather than black.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peninsula-morning",
|
||||||
|
door: "tera",
|
||||||
|
city: "sf",
|
||||||
|
chapter: 6,
|
||||||
|
expect: "Peninsula",
|
||||||
|
at: "2026-08-06T09:20:00-07:00",
|
||||||
|
place: "The Peninsula",
|
||||||
|
caption:
|
||||||
|
"Twenty miles of city between the water and the ridge, never more than four wide, with an aircraft on approach drawing the line across the bay. 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.",
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "socal-dtla",
|
||||||
|
door: "tera",
|
||||||
|
city: "socal",
|
||||||
|
chapter: 1,
|
||||||
|
expect: "DTLA",
|
||||||
|
at: "2026-08-06T18:10:00-07:00",
|
||||||
|
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.",
|
||||||
|
alt: "Downtown Los Angeles from above in late afternoon light, the river running past the tower cluster and the San Gabriel mountains standing behind the basin.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "office-floor",
|
||||||
|
door: "office",
|
||||||
|
chapter: 0,
|
||||||
|
expect: "The Floor",
|
||||||
|
at: "2026-08-06T11:20:00-07:00",
|
||||||
|
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.",
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "office-desks",
|
||||||
|
door: "office",
|
||||||
|
chapter: 2,
|
||||||
|
expect: "Desks",
|
||||||
|
at: "2026-08-06T11:20:00-07:00",
|
||||||
|
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.",
|
||||||
|
alt: "A closer view inside the office: rows of desks with chairs and monitors, people seated at them, bookshelves and planting behind a partition.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- 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 day = 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) {
|
||||||
|
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(shot.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, `${day}-${commit}${dirty ? "-dirty" : ""}`);
|
||||||
|
if (!preview) await mkdir(archiveDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const shot of wanted) {
|
||||||
|
const png = await shoot(browser, shot);
|
||||||
|
if (preview) {
|
||||||
|
await mkdir(join(ROOT, "shots", "preview"), { recursive: true });
|
||||||
|
await writeFile(join(ROOT, "shots", "preview", `${shot.id}.png`), png);
|
||||||
|
console.log("preview", shot.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(archiveDir, `${shot.id}.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({ shot, sizes });
|
||||||
|
console.log(
|
||||||
|
"shot ",
|
||||||
|
shot.id.padEnd(16),
|
||||||
|
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 { shot, sizes } of results) {
|
||||||
|
for (const size of SIZES) {
|
||||||
|
await writeFile(join(publicDir, `${shot.id}-${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} image set(s) → ${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} image sets → ${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 entries = SHOTS.map(
|
||||||
|
(s) => ` {
|
||||||
|
id: ${JSON.stringify(s.id)},
|
||||||
|
door: ${JSON.stringify(s.door)},
|
||||||
|
place: ${JSON.stringify(s.place)},
|
||||||
|
at: ${JSON.stringify(s.at)},
|
||||||
|
src: ${JSON.stringify(`/shots/${s.id}-1600.webp`)},
|
||||||
|
srcSmall: ${JSON.stringify(`/shots/${s.id}-800.webp`)},
|
||||||
|
width: ${SIZES[0].width},
|
||||||
|
height: ${Math.round(SIZES[0].width / aspect)},
|
||||||
|
alt: ${JSON.stringify(s.alt)},
|
||||||
|
caption: ${JSON.stringify(s.caption)},
|
||||||
|
},`,
|
||||||
|
).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.
|
||||||
|
*
|
||||||
|
* 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")};
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** The instant the engine's own clock was set to. The sun follows from it. */
|
||||||
|
at: string;
|
||||||
|
src: string;
|
||||||
|
srcSmall: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(day)};
|
||||||
|
|
||||||
|
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)!;
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user