skills/generate-icon/scripts/generate.ts

490 lines
16 KiB
TypeScript

#!/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<string, string | boolean> = {};
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 <name> --output <dir>");
console.error("\nOptions:");
console.error(" --preset <name> Platform preset (required)");
console.error(" --output <dir> Output directory (required)");
console.error(" --input <image> Reference image for style");
console.error(" --master-image <path> 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 <hex> 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<Image | null> {
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<void> }> {
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<boolean> {
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<number>(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<void>) | 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);
});