nuts / files / scripts / composite.ts
composite.ts
| 1 | /** |
| 2 | * scripts/composite.ts |
| 3 | * |
| 4 | * Stitches gallery.png + ui.png + settings.png into a single annotated |
| 5 | * composite sheet saved as screenshots/composite.png. |
| 6 | * |
| 7 | * Layout: |
| 8 | * ┌─────────── gallery ──────────────┐ |
| 9 | * │ │ |
| 10 | * ├──── ui ─────┬──── settings ──────┤ |
| 11 | * │ │ │ |
| 12 | * └─────────────┴────────────────────┘ |
| 13 | * |
| 14 | * Also optionally copies to screenshots/golden/composite.png. |
| 15 | */ |
| 16 | |
| 17 | import sharp from 'sharp' |
| 18 | import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' |
| 19 | import { join } from 'node:path' |
| 20 | import { fileURLToPath } from 'node:url' |
| 21 | |
| 22 | const ROOT = fileURLToPath(new URL('..', import.meta.url)) |
| 23 | const OUT_DIR = join(ROOT, 'screenshots') |
| 24 | const GOLDEN_DIR = join(OUT_DIR, 'golden') |
| 25 | |
| 26 | const BG = { r: 13, g: 13, b: 13 } // #0d0d0d |
| 27 | const PAD = 28 // outer padding |
| 28 | const GAP = 16 // gap between sections / columns |
| 29 | const LABEL_H = 32 // height of each section label bar |
| 30 | |
| 31 | function labelSvg(w: number, text: string): Buffer { |
| 32 | const svg = `<svg width="${w}" height="${LABEL_H}" xmlns="http://www.w3.org/2000/svg"> |
| 33 | <rect width="${w}" height="${LABEL_H}" fill="#1e1e1e" rx="4"/> |
| 34 | <text x="${w / 2}" y="${Math.round(LABEL_H * 0.72)}" |
| 35 | text-anchor="middle" |
| 36 | font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" |
| 37 | font-size="14" fill="#888888">${text}</text> |
| 38 | </svg>` |
| 39 | return Buffer.from(svg) |
| 40 | } |
| 41 | |
| 42 | async function meta(file: string): Promise<{ w: number; h: number }> { |
| 43 | const m = await sharp(file).metadata() |
| 44 | return { w: m.width!, h: m.height! } |
| 45 | } |
| 46 | |
| 47 | export async function makeComposite(update = false): Promise<string> { |
| 48 | const galleryFile = join(OUT_DIR, 'gallery.png') |
| 49 | const uiFile = join(OUT_DIR, 'ui.png') |
| 50 | const settingsFile = join(OUT_DIR, 'settings.png') |
| 51 | |
| 52 | for (const f of [galleryFile, uiFile, settingsFile]) { |
| 53 | if (!existsSync(f)) throw new Error(`Missing screenshot: ${f}`) |
| 54 | } |
| 55 | |
| 56 | const [g, u, s] = await Promise.all([ |
| 57 | meta(galleryFile), |
| 58 | meta(uiFile), |
| 59 | meta(settingsFile), |
| 60 | ]) |
| 61 | |
| 62 | // ── Compute layout ──────────────────────────────────────────────────── |
| 63 | // Both sections (gallery row, pair row) may have different widths. |
| 64 | // The composite width is the wider of the two, plus outer padding. |
| 65 | const pairInnerW = u.w + GAP + s.w |
| 66 | const innerW = Math.max(g.w, pairInnerW) |
| 67 | const totalW = innerW + PAD * 2 |
| 68 | |
| 69 | // Horizontal offsets (center each section within innerW) |
| 70 | const gLeft = PAD + Math.floor((innerW - g.w) / 2) |
| 71 | const pairLeft = PAD + Math.floor((innerW - pairInnerW) / 2) |
| 72 | const uLeft = pairLeft |
| 73 | const sLeft = pairLeft + u.w + GAP |
| 74 | |
| 75 | // Vertical positions |
| 76 | let y = PAD |
| 77 | const gLabelTop = y; y += LABEL_H |
| 78 | const gTop = y; y += g.h + GAP |
| 79 | const pairLabelTopU = y |
| 80 | const pairLabelTopS = y; y += LABEL_H |
| 81 | const pairTop = y; y += Math.max(u.h, s.h) |
| 82 | const totalH = y + PAD |
| 83 | |
| 84 | // ── Build composite layers ──────────────────────────────────────────── |
| 85 | const layers: sharp.OverlayOptions[] = [ |
| 86 | // Gallery label |
| 87 | { input: labelSvg(g.w, 'gallery'), left: gLeft, top: gLabelTop }, |
| 88 | // Gallery image |
| 89 | { input: galleryFile, left: gLeft, top: gTop }, |
| 90 | // UI label |
| 91 | { input: labelSvg(u.w, 'ui'), left: uLeft, top: pairLabelTopU }, |
| 92 | // Settings label |
| 93 | { input: labelSvg(s.w, 'settings'), left: sLeft, top: pairLabelTopS }, |
| 94 | // UI image |
| 95 | { input: uiFile, left: uLeft, top: pairTop }, |
| 96 | // Settings image |
| 97 | { input: settingsFile, left: sLeft, top: pairTop }, |
| 98 | ] |
| 99 | |
| 100 | const outFile = join(OUT_DIR, 'composite.png') |
| 101 | await sharp({ |
| 102 | create: { width: totalW, height: totalH, channels: 3, background: BG }, |
| 103 | }) |
| 104 | .composite(layers) |
| 105 | .png({ compressionLevel: 6 }) |
| 106 | .toFile(outFile) |
| 107 | |
| 108 | if (update) { |
| 109 | mkdirSync(GOLDEN_DIR, { recursive: true }) |
| 110 | writeFileSync(join(GOLDEN_DIR, 'composite.png'), readFileSync(outFile)) |
| 111 | } |
| 112 | |
| 113 | return outFile |
| 114 | } |