#!/usr/bin/env bun /** * Generate Icon - Main entry point * * Pipeline: * 1. Generate master image via Gemini (or use provided master) * 2. Remove background via Replicate rembg * 3. Crop whitespace and center on square canvas * 4. Export all sizes for the selected preset * 5. Generate ICO/ICNS bundles if needed */ import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { join, resolve, dirname } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; const { resolvePluginRoot } = await import(resolve(import.meta.dir, "../../../resolve-root.ts")).catch(async () => { // Fallback: find resolve-root.ts via env var or Claude Code plugin paths const _tryPaths = [process.env.GEMSKILLS_ROOT || ""]; const home = process.env.HOME || process.env.USERPROFILE || ""; try { const d = JSON.parse((await import("fs")).readFileSync(resolve(home, ".claude/plugins/installed_plugins.json"), "utf-8")); const ip = d.plugins?.["gemskills@b-open-io"]?.[0]?.installPath; if (ip) _tryPaths.push(ip); } catch {} try { const cd = resolve(home, ".claude/plugins/cache/b-open-io/gemskills"); const vs = (await import("fs")).readdirSync(cd).filter((v: string) => /^\d+\./.test(v)).sort(); for (let i = vs.length - 1; i >= 0; i--) _tryPaths.push(resolve(cd, vs[i])); } catch {} for (const p of _tryPaths) { try { if (p) return await import(resolve(p, "resolve-root.ts")); } catch {} } throw new Error("Cannot find gemskills. Set GEMSKILLS_ROOT or: claude plugin install gemskills@b-open-io"); }); const PLUGIN_ROOT = resolvePluginRoot(import.meta.dir); const { callGeminiImage } = await import(resolve(PLUGIN_ROOT, "utils.ts")) as typeof import("../../../utils"); import { removeBackground, cropAndCenter, resizeToPreset, generateICO, generateICNS, prepareIconset, fillBackground, } from "./process"; import { getPreset, listPresets, getPresetInfo, PRESETS } from "./presets"; import type { Image } from "@google/genai"; import sharp from "sharp"; // Icon-optimized prompt template const ICON_PROMPT_TEMPLATE = `Generate an icon design for: {userPrompt} CRITICAL REQUIREMENTS: - Simple, bold, recognizable silhouette - Works at small sizes (16x16 to 1024x1024) - Clean edges, no fine details - Centered with breathing room (5-10% padding) - High contrast, single focal point - NO text, NO gradients that band - Solid uniform background (will be removed) - Professional app icon quality`; interface Args { prompt: string; preset: string; output: string; input?: string; masterImage?: string; skipGenerate: boolean; skipRemoveBg: boolean; backgroundColor?: string; } function parseArgs(): Args { const args = process.argv.slice(2); const parsed: Record = {}; const positional: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith("--")) { const key = arg.slice(2); const nextArg = args[i + 1]; if (nextArg && !nextArg.startsWith("--")) { parsed[key] = nextArg; i++; } else { parsed[key] = true; } } else { positional.push(arg); } } const prompt = positional.join(" "); const preset = parsed.preset as string; const output = parsed.output as string; // Validate required args if (!preset) { console.error("Error: --preset is required"); console.error("\nAvailable presets:"); for (const name of listPresets()) { console.error(` ${name}: ${getPresetInfo(name)}`); } console.error("\nUsage: bun run icon \"prompt\" --preset --output "); console.error("\nOptions:"); console.error(" --preset Platform preset (required)"); console.error(" --output Output directory (required)"); console.error(" --input Reference image for style"); console.error(" --master-image Use existing master image"); console.error(" --skip-generate Skip AI generation (requires --master-image)"); console.error(" --skip-remove-bg Skip background removal"); console.error(" --bg-color Background color for non-transparent presets"); process.exit(1); } if (!output) { console.error("Error: --output is required"); process.exit(1); } if (!getPreset(preset)) { console.error(`Error: Unknown preset "${preset}"`); console.error("\nAvailable presets:"); for (const name of listPresets()) { console.error(` ${name}: ${getPresetInfo(name)}`); } process.exit(1); } const skipGenerate = parsed["skip-generate"] === true; const masterImage = parsed["master-image"] as string | undefined; if (skipGenerate && !masterImage) { console.error("Error: --skip-generate requires --master-image"); process.exit(1); } if (!skipGenerate && !prompt && !masterImage) { console.error("Error: Prompt required (or use --master-image with --skip-generate)"); process.exit(1); } return { prompt, preset, output: resolve(output), input: parsed.input as string | undefined, masterImage: masterImage ? resolve(masterImage) : undefined, skipGenerate, skipRemoveBg: parsed["skip-remove-bg"] === true, backgroundColor: parsed["bg-color"] as string | undefined, }; } function getApiKey(): string { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { console.error("Error: GEMINI_API_KEY environment variable not set"); process.exit(1); } return apiKey; } async function loadImage(filePath: string): Promise { if (!existsSync(filePath)) { console.error(`Warning: Image not found: ${filePath}`); return null; } const buffer = await readFile(filePath); const base64 = buffer.toString("base64"); const ext = filePath.toLowerCase().split(".").pop(); const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png"; return { imageBytes: base64, mimeType }; } async function prepareInputImagePath(filePath: string): Promise<{ path: string; cleanup?: () => Promise }> { const ext = filePath.toLowerCase().split(".").pop(); if (ext !== "svg") return { path: filePath }; const tempPath = join(tmpdir(), `generate-icon-input-${randomUUID()}.png`); await sharp(filePath) .resize(1024, 1024, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 0 }, }) .png() .toFile(tempPath); return { path: tempPath, cleanup: async () => { await rm(tempPath, { force: true }); }, }; } /** * Detects the common "checkerboard transparency preview" artifact that * image models sometimes bake into generated pixels. */ async function hasCheckerboardArtifact(filePath: string): Promise { const { data, info } = await sharp(filePath) .resize(256, 256, { fit: "inside" }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const x0 = Math.floor(info.width * 0.2); const x1 = Math.floor(info.width * 0.8); const y0 = Math.floor(info.height * 0.2); const y1 = Math.floor(info.height * 0.8); const bins = new Array(256).fill(0); let total = 0; for (let y = y0; y < y1; y++) { for (let x = x0; x < x1; x++) { const i = (y * info.width + x) * 4; const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const a = data[i + 3]; if (a < 245) continue; // gray-ish pixels only const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max - min > 8) continue; const v = Math.round((r + g + b) / 3); bins[v] += 1; total += 1; } } if (total < 1000) return false; const peaks = bins .map((count, value) => ({ count, value })) .sort((a, b) => b.count - a.count) .slice(0, 2); if (peaks.length < 2) return false; const [p1, p2] = peaks; const dominantRatio = (p1.count + p2.count) / total; if (Math.abs(p1.value - p2.value) < 12) return false; if (dominantRatio < 0.45) return false; // Alternating pattern test. let alternating = 0; let comparable = 0; const classify = (v: number) => { const d1 = Math.abs(v - p1.value); const d2 = Math.abs(v - p2.value); if (Math.min(d1, d2) > 8) return 0; return d1 <= d2 ? 1 : 2; }; for (let y = y0; y < y1; y++) { for (let x = x0; x < x1 - 1; x++) { const i1 = (y * info.width + x) * 4; const i2 = (y * info.width + x + 1) * 4; const a1 = data[i1 + 3]; const a2 = data[i2 + 3]; if (a1 < 245 || a2 < 245) continue; const v1 = Math.round((data[i1] + data[i1 + 1] + data[i1 + 2]) / 3); const v2 = Math.round((data[i2] + data[i2 + 1] + data[i2 + 2]) / 3); const c1 = classify(v1); const c2 = classify(v2); if (!c1 || !c2) continue; comparable += 1; if (c1 !== c2) alternating += 1; } } if (comparable < 1000) return false; return alternating / comparable > 0.28; } async function main() { const args = parseArgs(); const preset = getPreset(args.preset)!; console.log(`\n=== Generate Icon: ${preset.name} ===\n`); console.log(`Output: ${args.output}`); console.log(`Sizes: ${preset.sizes.length} variants`); console.log(`Master: ${preset.masterSize}x${preset.masterSize}px\n`); // Create output directory await mkdir(args.output, { recursive: true }); const masterRawPath = join(args.output, "master-raw.png"); const masterNoBgPath = join(args.output, "master-nobg.png"); const masterCroppedPath = join(args.output, "master-cropped.png"); const masterFinalPath = join(args.output, "master-final.png"); // Stage 1: Generate or use existing master if (args.masterImage) { console.log("Stage 1: Using provided master image"); if (!existsSync(args.masterImage)) { console.error(`Error: Master image not found: ${args.masterImage}`); process.exit(1); } // Copy to output directory await writeFile(masterRawPath, await readFile(args.masterImage)); } else if (!args.skipGenerate) { console.log("Stage 1: Generating master image via Gemini..."); const apiKey = getApiKey(); const basePrompt = ICON_PROMPT_TEMPLATE.replace("{userPrompt}", args.prompt); const options: { imageSize: string; aspectRatio: string; inputImage?: Image; } = { imageSize: "1K", // Generate at 1K, will resize to preset master aspectRatio: "1:1", }; // Add reference image if provided let inputImageCleanup: (() => Promise) | undefined; if (args.input) { const prepared = await prepareInputImagePath(args.input); inputImageCleanup = prepared.cleanup; const inputImage = await loadImage(prepared.path); if (inputImage) { console.log(` Using reference image: ${args.input}`); options.inputImage = inputImage; } } try { let saved = false; for (let attempt = 1; attempt <= 2; attempt++) { const iconPrompt = attempt === 1 ? basePrompt : `${basePrompt}\n\nCRITICAL: Do NOT render checkerboard transparency previews, tiled transparency patterns, or gray square grids. Keep interior regions as solid color or true transparency only.`; const result = await callGeminiImage(apiKey, iconPrompt, options); if (result.images.length === 0) { console.error("Error: No image generated"); if (result.text) console.error(`Model response: ${result.text}`); process.exit(1); } // Save master const imageData = result.images[0]; await writeFile(masterRawPath, Buffer.from(imageData.data, "base64")); const checkerboard = await hasCheckerboardArtifact(masterRawPath); if (checkerboard && attempt < 2) { console.warn(" Detected checkerboard artifact in generated icon; retrying once..."); continue; } if (checkerboard) { console.warn(" Warning: checkerboard-like artifact may still be present in generated icon."); } console.log(` Saved: master-raw.png`); saved = true; break; } if (!saved) { console.error("Error: Failed to save generated master icon"); process.exit(1); } } finally { if (inputImageCleanup) { await inputImageCleanup(); } } } else { console.log("Stage 1: Skipped (--skip-generate)"); } // Stage 2: Remove background let currentMaster = masterRawPath; if (!args.skipRemoveBg) { console.log("\nStage 2: Removing background..."); await removeBackground(currentMaster, masterNoBgPath); currentMaster = masterNoBgPath; console.log(` Saved: master-nobg.png`); } else { console.log("\nStage 2: Skipped (--skip-remove-bg)"); } // Stage 3: Crop and center console.log("\nStage 3: Cropping and centering..."); await cropAndCenter(currentMaster, masterCroppedPath, { targetSize: preset.masterSize, padding: 0.05, }); currentMaster = masterCroppedPath; console.log(` Saved: master-cropped.png (${preset.masterSize}x${preset.masterSize})`); // Stage 3.5: Fill background for non-transparent presets (iOS) if (!preset.requiresTransparency) { console.log("\nStage 3.5: Filling background (preset requires no transparency)..."); const bgColor = args.backgroundColor ? hexToRgb(args.backgroundColor) : { r: 255, g: 255, b: 255 }; await fillBackground(currentMaster, masterFinalPath, bgColor); currentMaster = masterFinalPath; console.log(` Saved: master-final.png (white background)`); } // Stage 4: Export all sizes console.log("\nStage 4: Exporting all sizes..."); const createdFiles = await resizeToPreset(currentMaster, args.output, args.preset); console.log(` Created ${createdFiles.length} icon files`); // Stage 5: Generate bundles if needed if (preset.generateBundle) { console.log("\nStage 5: Generating bundles..."); if (preset.format === "ico") { const icoPath = join(args.output, "favicon.ico"); const success = await generateICO(args.output, icoPath); if (success) { console.log(` Created: favicon.ico`); createdFiles.push(icoPath); } } if (preset.format === "icns") { const iconsetDir = join(args.output, "AppIcon.iconset"); const icnsPath = join(args.output, "AppIcon.icns"); // Prepare iconset directory await prepareIconset(currentMaster, iconsetDir); console.log(` Prepared: AppIcon.iconset/`); const success = await generateICNS(iconsetDir, icnsPath); if (success) { console.log(` Created: AppIcon.icns`); createdFiles.push(icnsPath); // Clean up iconset directory await rm(iconsetDir, { recursive: true }); } } } else { console.log("\nStage 5: Skipped (no bundle required)"); } // Summary console.log("\n=== Complete ===\n"); console.log(`Output directory: ${args.output}`); console.log(`Total files: ${createdFiles.length + 3} (including masters)`); console.log("\nMaster files:"); console.log(` - master-raw.png`); if (!args.skipRemoveBg) console.log(` - master-nobg.png`); console.log(` - master-cropped.png`); if (!preset.requiresTransparency) console.log(` - master-final.png`); console.log("\nIcon files:"); for (const file of createdFiles.slice(0, 5)) { console.log(` - ${file.replace(args.output + "/", "")}`); } if (createdFiles.length > 5) { console.log(` ... and ${createdFiles.length - 5} more`); } } function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", ""); const bigint = parseInt(clean, 16); return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255, }; } main().catch((err) => { console.error("Error:", err.message); process.exit(1); });