tower / files / tools / photos.mjs
photos.mjs
3cc2b5bd 2026-10-04 Pavel Safronov | 1 | /** |
| 2 | * Photographs. `npm run photos` | |
| 3 | * | |
| 4 | * Renders every shot in content/photos.json to photos/<id>.png: the view alone — no panel, | |
| 5 | * no readout, no labels — at the shape it was composed in, 3840 px along its long edge. | |
| 6 | * | |
| 7 | * The list is the thing kept; the renders are made from it and are not committed. A shot is | |
| 8 | * saved from the running app (P, or the button, at `npm run dev`) or typed by hand, and its | |
| 9 | * file name never changes, so rendering again replaces the picture with the latest version | |
| 10 | * of the same picture: the world as it is now, drawn as the renderer draws it now. | |
| 11 | * | |
| 12 | * Unlike the screenshot tour (screenshots.mjs), a photo is posed directly rather than | |
| 13 | * through the UI. That tour is a test of the application; this is a camera. | |
| 14 | * | |
| 15 | * Flags: | |
| 16 | * --only <id,id> render just these shots (id prefixes) | |
| 17 | * --long-edge <px> the long edge, in pixels (default 3840) | |
| 18 | * --url <origin> photograph a server already running instead of starting one | |
| 19 | */ | |
| 20 | import { mkdir, readFile } from 'node:fs/promises' | |
| 21 | import path from 'node:path' | |
| 22 | import { fileURLToPath } from 'node:url' | |
| 23 | import { createServer } from 'vite' | |
| 24 | import { launch, seedRandom, settle, warmWorld } from './browser.mjs' | |
| 25 | ||
| 26 | const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') | |
| 27 | const LIST = path.join(ROOT, 'content', 'photos.json') | |
| 28 | const OUT = path.join(ROOT, 'photos') | |
| 29 | ||
| 30 | /** As the screenshot tour asks: two terrain workers, and the compiled world from the cache. */ | |
| 31 | const PARAMS = 'workers=2&cache=1' | |
| 32 | ||
| 33 | /** | |
| 34 | * The view and nothing else. The panel's column is given back to the world, and everything | |
| 35 | * laid over the canvas — labels, readouts, the gauge, the view buttons — is taken away. | |
| 36 | */ | |
| 37 | const BARE = ` | |
| 38 | #app { grid-template-columns: 1fr !important; } | |
| 39 | aside, #panel-toggle, #scrim, main > :not(canvas) { display: none !important; } | |
| 40 | ` | |
| 41 | ||
| 42 | const argv = process.argv.slice(2) | |
| 43 | const flag = (name) => { | |
| 44 | const i = argv.indexOf(name) | |
| 45 | return i >= 0 ? argv[i + 1] : undefined | |
| 46 | } | |
| 47 | const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean) | |
| 48 | const longEdge = Number(flag('--long-edge') ?? 3840) | |
| 49 | const givenUrl = flag('--url') | |
| 50 | ||
| 51 | const list = JSON.parse(await readFile(LIST, 'utf8').catch(() => '[]')) | |
| 52 | const photos = list.filter((p) => only.length === 0 || only.some((o) => String(p.id).startsWith(o))) | |
| 53 | if (!photos.length) { | |
| 54 | console.log(only.length | |
| 55 | ? `No shot in content/photos.json matches ${only.join(', ')}.` | |
| 56 | : 'content/photos.json has no shots yet: run `npm run dev`, frame one, and press P.') | |
| 57 | process.exit(only.length ? 1 : 0) | |
| 58 | } | |
| 59 | ||
| 60 | async function take(browser, base, storage, photo) { | |
| 61 | const t0 = Date.now() | |
| 62 | /** | |
| 63 | * Two device pixels to the CSS pixel, which is the most the views will draw at, and what | |
| 64 | * the author's own screen does: the stars and the lines come out the weight they were | |
| 65 | * when the shot was framed, rather than half of it across a 3840 px window. | |
| 66 | */ | |
| 67 | const long = Math.round(longEdge / 2) | |
| 68 | const short = Math.round(long / Math.max(photo.aspect, 1 / photo.aspect)) | |
| 69 | const viewport = photo.aspect >= 1 ? { width: long, height: short } : { width: short, height: long } | |
| 70 | const context = await browser.newContext({ storageState: storage, viewport, deviceScaleFactor: 2 }) | |
| 71 | await seedRandom(context) | |
| 72 | const page = await context.newPage() | |
| 73 | const problems = [] | |
| 74 | page.on('pageerror', (e) => problems.push(String(e))) | |
| 75 | page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) }) | |
| 76 | ||
| 77 | // The clock is named in the address so the page never opens at the time now, running. | |
| 78 | await page.goto(`${base}/?${PARAMS}&clock=${photo.hours}#view=${photo.view}`, { waitUntil: 'networkidle' }) | |
| 79 | await page.addStyleTag({ content: BARE }) | |
| 80 | await settle(page) | |
| 81 | // The app checks every field, and says which is wrong: the list is edited by hand too. | |
| 82 | await page.evaluate((p) => window.towerPhotos.show(p), photo) | |
| 83 | await settle(page, 1500) | |
| 84 | ||
| 85 | const file = path.join(OUT, `${photo.id}.png`) | |
| 86 | await page.screenshot({ path: file }) | |
| 87 | await context.close() | |
| 88 | const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : '' | |
| 89 | console.log(` ${path.relative(ROOT, file)} ${viewport.width * 2}×${viewport.height * 2} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`) | |
| 90 | } | |
| 91 | ||
| 92 | await mkdir(OUT, { recursive: true }) | |
| 93 | const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } }) | |
| 94 | if (server) await server.listen() | |
| 95 | const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '') | |
| 96 | console.log(`\nTower photographs — ${base}\n`) | |
| 97 | ||
| 98 | let failed = 0 | |
| 99 | try { | |
| 100 | const browser = await launch() | |
| 101 | try { | |
| 102 | const storage = await warmWorld(browser, base, `${PARAMS}&clock=12`) | |
| 103 | // One at a time: each is a 4K frame on the GPU, and there are only ever a handful. | |
| 104 | for (const photo of photos) { | |
| 105 | try { | |
| 106 | await take(browser, base, storage, photo) | |
| 107 | } catch (e) { | |
| 108 | failed++ | |
| 109 | console.log(` ${photo.id} ✗ ${String(e).split('\n')[0]}`) | |
| 110 | } | |
| 111 | } | |
| 112 | } finally { | |
| 113 | await browser.close() | |
| 114 | } | |
| 115 | } finally { | |
| 116 | await server?.close() | |
| 117 | } | |
| 118 | if (failed) process.exitCode = 1 | |
| 119 | console.log(`\nDone: ${photos.length - failed} of ${photos.length} in photos/.\n`) |