nuts / files / scripts / composite.ts
composite.ts
88e406aa 2026-04-13 Pavel Safronov | 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 | } |