bubbles / files / scripts / generate-assets.ts
generate-assets.ts
dcc4757f 2026-04-09 Pavel Safronov | 1 | /** |
| 2 | * Asset generation script. | |
| 3 | * Run with: pnpm gen-assets | |
| 4 | * | |
| 5 | * Reads wind.png from the repo root, applies a blue tint, and writes: | |
| 6 | * static/favicon.ico (32×32 ICO via PNG fallback — modern browsers accept PNG as .ico) | |
| 7 | * static/og-image.png (1200×630 social card with logo centred on a dark background) | |
| 8 | */ | |
| 9 | ||
| 10 | import sharp from 'sharp'; | |
| 11 | import path from 'path'; | |
| 12 | import fs from 'fs'; | |
| 13 | import { fileURLToPath } from 'url'; | |
| 14 | ||
| 15 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 16 | const root = path.resolve(__dirname, '..'); | |
| 17 | const src = path.join(root, 'wind.png'); | |
| 18 | const staticDir = path.join(root, 'static'); | |
| 19 | ||
| 20 | if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true }); | |
| 21 | ||
| 22 | // ── Shared: tinted white→blue version of the logo ───────────────────────── | |
| 23 | ||
| 24 | async function makeTintedLogo(size: number): Promise<Buffer> { | |
| 25 | // 1. Resize to target size | |
| 26 | // 2. Ensure RGBA | |
| 27 | // 3. Tint: the source is black-on-white; we invert so strokes are white, | |
| 28 | // then composite a blue colour behind it. | |
| 29 | const logoAlpha = await sharp(src) | |
| 30 | .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) | |
| 31 | .negate({ alpha: false }) // invert: black strokes → white strokes | |
| 32 | .toColorspace('srgb') | |
| 33 | .ensureAlpha() | |
| 34 | .raw() | |
| 35 | .toBuffer({ resolveWithObject: true }); | |
| 36 | ||
| 37 | // Build a solid blue background of the same size | |
| 38 | const blue = await sharp({ | |
| 39 | create: { width: size, height: size, channels: 4, background: { r: 14, g: 165, b: 233, alpha: 255 } }, | |
| 40 | }) | |
| 41 | .png() | |
| 42 | .toBuffer(); | |
| 43 | ||
| 44 | // Composite white logo on blue background | |
| 45 | return sharp(blue) | |
| 46 | .composite([{ input: logoAlpha.data, raw: { width: size, height: size, channels: 4 }, blend: 'over' }]) | |
| 47 | .png() | |
| 48 | .toBuffer(); | |
| 49 | } | |
| 50 | ||
| 51 | // ── favicon.ico (PNG saved as .ico — accepted by all modern browsers) ────── | |
| 52 | ||
| 53 | async function genFavicon(): Promise<void> { | |
| 54 | const buf = await makeTintedLogo(32); | |
| 55 | const out = path.join(staticDir, 'favicon.ico'); | |
| 56 | fs.writeFileSync(out, buf); | |
| 57 | console.log(`✓ favicon.ico (32×32)`); | |
| 58 | } | |
| 59 | ||
| 60 | // ── og-image.png (1200×630) ─────────────────────────────────────────────── | |
| 61 | ||
| 62 | async function genOgImage(): Promise<void> { | |
| 63 | const W = 1200, H = 630; | |
| 64 | const logoSize = 220; | |
| 65 | ||
| 66 | const logo = await makeTintedLogo(logoSize); | |
| 67 | ||
| 68 | // Dark-blue background | |
| 69 | const bg = await sharp({ | |
| 70 | create: { width: W, height: H, channels: 3, background: { r: 15, g: 23, b: 42 } }, | |
| 71 | }) | |
| 72 | .png() | |
| 73 | .toBuffer(); | |
| 74 | ||
| 75 | const out = await sharp(bg) | |
| 76 | .composite([{ | |
| 77 | input: logo, | |
| 78 | left: Math.round((W - logoSize) / 2), | |
| 79 | top: Math.round((H - logoSize) / 2) - 40, | |
| 80 | }]) | |
| 81 | .png() | |
| 82 | .toBuffer(); | |
| 83 | ||
| 84 | fs.writeFileSync(path.join(staticDir, 'og-image.png'), out); | |
| 85 | console.log(`✓ og-image.png (${W}×${H})`); | |
| 86 | } | |
| 87 | ||
| 88 | // ── run ──────────────────────────────────────────────────────────────────── | |
| 89 | ||
| 90 | async function main(): Promise<void> { | |
| 91 | await genFavicon(); | |
| 92 | await genOgImage(); | |
| 93 | console.log('Asset generation complete.'); | |
| 94 | } | |
| 95 | ||
| 96 | main().catch((err: unknown) => { console.error(err); process.exit(1); }); |