bubbles / files / scripts / generate-assets.ts
generate-assets.ts
| 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); }); |