fuzzyworld

nuts / files / scripts / composite.ts

composite.ts

4.4 KB · history · blame

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
17import sharp from 'sharp'
18import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
19import { join } from 'node:path'
20import { fileURLToPath } from 'node:url'
21
22const ROOT = fileURLToPath(new URL('..', import.meta.url))
23const OUT_DIR = join(ROOT, 'screenshots')
24const GOLDEN_DIR = join(OUT_DIR, 'golden')
25
26const BG = { r: 13, g: 13, b: 13 } // #0d0d0d
27const PAD = 28 // outer padding
28const GAP = 16 // gap between sections / columns
29const LABEL_H = 32 // height of each section label bar
30
31function 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
42async 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
47export 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}