tower / files / tools / photos.mjs
photos.mjs
| 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`) |