|
1 | 1 | #!/usr/bin/env bun |
2 | | -import { mkdir, rm } from "node:fs/promises"; |
| 2 | +/** |
| 3 | + * Icon generation script for mux. |
| 4 | + * |
| 5 | + * Usage: |
| 6 | + * bun scripts/generate-icons.ts [commands...] |
| 7 | + * |
| 8 | + * Commands: |
| 9 | + * update <source> - Update all logo files from a source image (webp/png/jpg) |
| 10 | + * png - Generate build/icon.png (512x512) |
| 11 | + * icns - Generate build/icon.icns (macOS app icon) |
| 12 | + * |
| 13 | + * If no command is given, defaults to: png icns |
| 14 | + * |
| 15 | + * Examples: |
| 16 | + * bun scripts/generate-icons.ts update ~/Pictures/new-logo.webp |
| 17 | + * bun scripts/generate-icons.ts png icns |
| 18 | + */ |
| 19 | +import { mkdir, rm, copyFile, writeFile } from "node:fs/promises"; |
3 | 20 | import path from "node:path"; |
4 | 21 | import { fileURLToPath } from "node:url"; |
5 | 22 | import sharp from "sharp"; |
6 | 23 |
|
7 | | -const SIZES = [16, 32, 64, 128, 256, 512]; |
| 24 | +const ICONSET_SIZES = [16, 32, 64, 128, 256, 512]; |
8 | 25 |
|
9 | 26 | const __filename = fileURLToPath(import.meta.url); |
10 | 27 | const __dirname = path.dirname(__filename); |
11 | 28 | const ROOT = path.resolve(__dirname, ".."); |
| 29 | + |
| 30 | +// Source logo - all other icons are derived from this |
12 | 31 | const SOURCE = path.join(ROOT, "docs", "img", "logo.webp"); |
| 32 | + |
| 33 | +// Build outputs |
13 | 34 | const BUILD_DIR = path.join(ROOT, "build"); |
14 | 35 | const ICONSET_DIR = path.join(BUILD_DIR, "icon.iconset"); |
15 | 36 | const PNG_OUTPUT = path.join(BUILD_DIR, "icon.png"); |
16 | 37 | const ICNS_OUTPUT = path.join(BUILD_DIR, "icon.icns"); |
17 | 38 |
|
18 | | -const args = new Set(process.argv.slice(2)); |
19 | | -if (args.size === 0) { |
20 | | - args.add("png"); |
21 | | - args.add("icns"); |
| 39 | +// All logo locations that need updating (icon-only, not text logos) |
| 40 | +const LOGO_TARGETS = { |
| 41 | + // VS Code extension |
| 42 | + "vscode/icon.png": { size: 128 }, |
| 43 | + // Browser asset |
| 44 | + "src/browser/assets/icons/mux.svg": { size: 1024, svg: true }, |
| 45 | +} as const; |
| 46 | + |
| 47 | +const FAVICON_SIZES = [16, 32, 48, 64, 128, 256]; |
| 48 | + |
| 49 | +async function generatePngFromSource(source: string, output: string, size: number) { |
| 50 | + await sharp(source).resize(size, size).toFile(output); |
22 | 51 | } |
23 | | -const needsPng = args.has("png") || args.has("icns"); |
24 | | -const needsIcns = args.has("icns"); |
25 | 52 |
|
26 | | -async function generateIconPng() { |
| 53 | +async function generateSvgWithEmbeddedPng(source: string, output: string, size: number) { |
| 54 | + const pngBuffer = await sharp(source).resize(size, size).png().toBuffer(); |
| 55 | + const base64 = pngBuffer.toString("base64"); |
| 56 | + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> |
| 57 | + <image width="${size}" height="${size}" xlink:href="data:image/png;base64,${base64}"/> |
| 58 | +</svg> |
| 59 | +`; |
| 60 | + await writeFile(output, svg); |
| 61 | +} |
| 62 | + |
| 63 | +async function generateFavicon(source: string, output: string) { |
| 64 | + // Use ImageMagick if available for proper multi-resolution ICO |
| 65 | + try { |
| 66 | + const proc = Bun.spawn( |
| 67 | + [ |
| 68 | + "magick", |
| 69 | + source, |
| 70 | + "-resize", |
| 71 | + "256x256", |
| 72 | + "-define", |
| 73 | + `icon:auto-resize=${FAVICON_SIZES.join(",")}`, |
| 74 | + output, |
| 75 | + ], |
| 76 | + { stdout: "ignore", stderr: "ignore" } |
| 77 | + ); |
| 78 | + const status = await proc.exited; |
| 79 | + if (status === 0) return; |
| 80 | + } catch { |
| 81 | + // ImageMagick not available |
| 82 | + } |
| 83 | + |
| 84 | + // Fallback: just use the 256x256 PNG renamed as ICO (works in most browsers) |
| 85 | + const pngBuffer = await sharp(source).resize(256, 256).png().toBuffer(); |
| 86 | + await writeFile(output, pngBuffer); |
| 87 | + console.warn(" ⚠ ImageMagick not found, favicon.ico is single-resolution"); |
| 88 | +} |
| 89 | + |
| 90 | +async function updateAllLogos(sourcePath: string) { |
| 91 | + const resolvedSource = path.resolve(sourcePath); |
| 92 | + console.log(`Updating all logos from: ${resolvedSource}\n`); |
| 93 | + |
| 94 | + // First, copy source to canonical location |
| 95 | + const sourceExt = path.extname(resolvedSource).toLowerCase(); |
| 96 | + if (sourceExt === ".webp") { |
| 97 | + await copyFile(resolvedSource, SOURCE); |
| 98 | + console.log(`✓ docs/img/logo.webp (source)`); |
| 99 | + } else { |
| 100 | + // Convert to webp |
| 101 | + await sharp(resolvedSource).webp().toFile(SOURCE); |
| 102 | + console.log(`✓ docs/img/logo.webp (converted from ${sourceExt})`); |
| 103 | + } |
| 104 | + |
| 105 | + // Generate all PNG targets |
| 106 | + for (const [relativePath, config] of Object.entries(LOGO_TARGETS)) { |
| 107 | + const outputPath = path.join(ROOT, relativePath); |
| 108 | + if (config.svg) { |
| 109 | + await generateSvgWithEmbeddedPng(SOURCE, outputPath, config.size); |
| 110 | + } else { |
| 111 | + await generatePngFromSource(SOURCE, outputPath, config.size); |
| 112 | + } |
| 113 | + console.log(`✓ ${relativePath} (${config.size}x${config.size})`); |
| 114 | + } |
| 115 | + |
| 116 | + // Generate favicon.ico |
| 117 | + const faviconPath = path.join(ROOT, "public", "favicon.ico"); |
| 118 | + await generateFavicon(SOURCE, faviconPath); |
| 119 | + console.log(`✓ public/favicon.ico (multi-resolution)`); |
| 120 | + |
| 121 | + console.log("\n✅ All logos updated successfully!"); |
| 122 | +} |
| 123 | + |
| 124 | +async function generateBuildPng() { |
27 | 125 | await sharp(SOURCE).resize(512, 512).toFile(PNG_OUTPUT); |
28 | 126 | } |
29 | 127 |
|
30 | 128 | async function generateIconsetPngs() { |
31 | 129 | await mkdir(ICONSET_DIR, { recursive: true }); |
32 | 130 |
|
33 | | - const tasks = SIZES.flatMap((size) => { |
| 131 | + const tasks = ICONSET_SIZES.flatMap((size) => { |
34 | 132 | const outputs = [ |
35 | 133 | { |
36 | 134 | file: path.join(ICONSET_DIR, `icon_${size}x${size}.png`), |
@@ -66,15 +164,47 @@ async function generateIcns() { |
66 | 164 | } |
67 | 165 | } |
68 | 166 |
|
69 | | -await mkdir(BUILD_DIR, { recursive: true }); |
| 167 | +// Parse arguments |
| 168 | +const args = process.argv.slice(2); |
| 169 | +const commands = new Set<string>(); |
| 170 | +let updateSource: string | undefined; |
70 | 171 |
|
71 | | -if (needsPng) { |
72 | | - await generateIconPng(); |
| 172 | +for (let i = 0; i < args.length; i++) { |
| 173 | + if (args[i] === "update") { |
| 174 | + updateSource = args[++i]; |
| 175 | + if (!updateSource) { |
| 176 | + console.error("Error: 'update' command requires a source image path"); |
| 177 | + process.exit(1); |
| 178 | + } |
| 179 | + commands.add("update"); |
| 180 | + } else { |
| 181 | + commands.add(args[i]); |
| 182 | + } |
73 | 183 | } |
74 | 184 |
|
75 | | -if (needsIcns) { |
76 | | - await generateIconsetPngs(); |
77 | | - await generateIcns(); |
| 185 | +// Default to png + icns if no commands |
| 186 | +if (commands.size === 0) { |
| 187 | + commands.add("png"); |
| 188 | + commands.add("icns"); |
78 | 189 | } |
79 | 190 |
|
80 | | -await rm(ICONSET_DIR, { recursive: true, force: true }); |
| 191 | +// Execute commands |
| 192 | +if (commands.has("update") && updateSource) { |
| 193 | + await updateAllLogos(updateSource); |
| 194 | +} else { |
| 195 | + await mkdir(BUILD_DIR, { recursive: true }); |
| 196 | + |
| 197 | + const needsPng = commands.has("png") || commands.has("icns"); |
| 198 | + const needsIcns = commands.has("icns"); |
| 199 | + |
| 200 | + if (needsPng) { |
| 201 | + await generateBuildPng(); |
| 202 | + } |
| 203 | + |
| 204 | + if (needsIcns) { |
| 205 | + await generateIconsetPngs(); |
| 206 | + await generateIcns(); |
| 207 | + } |
| 208 | + |
| 209 | + await rm(ICONSET_DIR, { recursive: true, force: true }); |
| 210 | +} |
0 commit comments