/** * Image processing functions for icon generation * - Background removal via Replicate rembg * - Crop and center using sharp * - Resize to preset sizes * - ICO/ICNS bundle generation */ import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, join, basename } from "node:path"; import sharp from "sharp"; import { getPreset, type IconPreset } from "./presets"; /** * Remove background using Replicate's rembg model */ export async function removeBackground( inputPath: string, outputPath: string ): Promise { const REPLICATE_API_TOKEN = process.env.REPLICATE_API_TOKEN || process.env.REPLICATE_API_KEY; if (!REPLICATE_API_TOKEN) { throw new Error( "REPLICATE_API_TOKEN or REPLICATE_API_KEY environment variable not set" ); } // Read file and convert to base64 data URL const fileData = await readFile(inputPath); const base64 = fileData.toString("base64"); const mimeType = inputPath.endsWith(".png") ? "image/png" : "image/jpeg"; const imageData = `data:${mimeType};base64,${base64}`; console.log(" Removing background via Replicate (rembg model)..."); const response = await fetch("https://api.replicate.com/v1/predictions", { method: "POST", headers: { Authorization: `Bearer ${REPLICATE_API_TOKEN}`, "Content-Type": "application/json", Prefer: "wait", }, body: JSON.stringify({ version: "fb8af171cfa1616ddcf1242c093f9c46bcada5ad4cf6f2fbe8b81b330ec5c003", input: { image: imageData, }, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Replicate API error: ${response.status} - ${error}`); } const result = await response.json(); if (result.error) { throw new Error(`Replicate error: ${result.error}`); } if (result.status === "failed") { throw new Error(`Replicate prediction failed: ${JSON.stringify(result)}`); } const outputUrl = result.output; if (!outputUrl) { throw new Error(`No output URL in response: ${JSON.stringify(result)}`); } // Download result const imageResponse = await fetch(outputUrl); if (!imageResponse.ok) { throw new Error(`Failed to download result: ${imageResponse.status}`); } const imageBuffer = await imageResponse.arrayBuffer(); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, Buffer.from(imageBuffer)); } /** * Crop whitespace and center on square transparent canvas */ export async function cropAndCenter( inputPath: string, outputPath: string, options: { targetSize: number; padding?: number; // Percentage (0-1), default 0.05 (5%) } ): Promise { const { targetSize, padding = 0.05 } = options; // Load image and get trimmed bounds const image = sharp(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error("Could not read image dimensions"); } // Trim whitespace/transparent edges and get info const trimmed = await image.trim().toBuffer({ resolveWithObject: true }); const trimmedWidth = trimmed.info.width; const trimmedHeight = trimmed.info.height; // Calculate the content area with padding const contentSize = Math.max(trimmedWidth, trimmedHeight); const paddedSize = Math.ceil(contentSize / (1 - padding * 2)); // Calculate offset to center the trimmed content const offsetX = Math.floor((paddedSize - trimmedWidth) / 2); const offsetY = Math.floor((paddedSize - trimmedHeight) / 2); // Create centered image on square canvas const centered = await sharp({ create: { width: paddedSize, height: paddedSize, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }) .composite([ { input: trimmed.data, left: offsetX, top: offsetY, }, ]) .png() .toBuffer(); // Resize to target size await sharp(centered) .resize(targetSize, targetSize, { kernel: sharp.kernel.lanczos3, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png({ compressionLevel: 9 }) .toFile(outputPath); } /** * Resize image to all sizes defined in a preset */ export async function resizeToPreset( inputPath: string, outputDir: string, presetName: string ): Promise { const preset = getPreset(presetName); if (!preset) { throw new Error(`Unknown preset: ${presetName}`); } await mkdir(outputDir, { recursive: true }); const createdFiles: string[] = []; const processedSizes = new Set(); // Track size+filename combos to avoid duplicates for (const sizeSpec of preset.sizes) { const filename = sizeSpec.filename || `icon-${sizeSpec.size}.png`; const sizeKey = `${sizeSpec.size}-${filename}`; // Skip duplicates (some presets have same size for different contexts) if (processedSizes.has(sizeKey)) continue; processedSizes.add(sizeKey); const outputPath = join(outputDir, filename); // Ensure subdirectory exists for nested paths (e.g., mipmap-xxxhdpi/) await mkdir(dirname(outputPath), { recursive: true }); await sharp(inputPath) .resize(sizeSpec.size, sizeSpec.size, { kernel: sharp.kernel.lanczos3, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png({ compressionLevel: 9 }) .toFile(outputPath); createdFiles.push(outputPath); } return createdFiles; } /** * Generate ICO file from multiple PNG sizes using ImageMagick * Falls back to copying 32x32 as basic favicon.ico if ImageMagick unavailable */ export async function generateICO( pngDir: string, outputPath: string, sizes: number[] = [256, 128, 64, 48, 32, 24, 16] ): Promise { // Find available PNG files for the requested sizes const pngFiles: string[] = []; for (const size of sizes) { const candidates = [ join(pngDir, `icon-${size}.png`), join(pngDir, `favicon-${size}x${size}.png`), ]; for (const candidate of candidates) { if (existsSync(candidate)) { pngFiles.push(candidate); break; } } } if (pngFiles.length === 0) { console.log(" Warning: No PNG files found for ICO generation"); return false; } // Try ImageMagick convert try { const proc = Bun.spawnSync(["which", "convert"]); if (proc.exitCode === 0) { const convertProc = Bun.spawnSync([ "convert", ...pngFiles, outputPath, ]); if (convertProc.exitCode === 0) { return true; } } } catch { // ImageMagick not available } // Fallback: copy smallest available PNG as .ico // (browsers accept PNG content in .ico files) const fallbackPng = pngFiles[pngFiles.length - 1]; // Smallest size if (fallbackPng && existsSync(fallbackPng)) { await writeFile(outputPath, await readFile(fallbackPng)); console.log(" Note: ImageMagick not found, using PNG fallback for favicon.ico"); return true; } return false; } /** * Generate ICNS file from iconset directory using macOS iconutil */ export async function generateICNS( iconsetDir: string, outputPath: string ): Promise { // Verify iconset directory exists and has correct naming if (!iconsetDir.endsWith(".iconset")) { console.log(" Warning: iconset directory must end with .iconset"); return false; } if (!existsSync(iconsetDir)) { console.log(` Warning: iconset directory not found: ${iconsetDir}`); return false; } // Run iconutil try { const proc = Bun.spawnSync([ "iconutil", "-c", "icns", iconsetDir, "-o", outputPath, ]); if (proc.exitCode === 0) { return true; } else { console.log(` Warning: iconutil failed: ${proc.stderr.toString()}`); return false; } } catch (err) { console.log(` Warning: iconutil not available (macOS only)`); return false; } } /** * Prepare iconset directory for ICNS generation */ export async function prepareIconset( masterPath: string, iconsetDir: string ): Promise { await mkdir(iconsetDir, { recursive: true }); // macOS iconset sizes and filenames const iconsetSizes = [ { size: 16, filename: "icon_16x16.png" }, { size: 32, filename: "icon_16x16@2x.png" }, { size: 32, filename: "icon_32x32.png" }, { size: 64, filename: "icon_32x32@2x.png" }, { size: 128, filename: "icon_128x128.png" }, { size: 256, filename: "icon_128x128@2x.png" }, { size: 256, filename: "icon_256x256.png" }, { size: 512, filename: "icon_256x256@2x.png" }, { size: 512, filename: "icon_512x512.png" }, { size: 1024, filename: "icon_512x512@2x.png" }, ]; for (const { size, filename } of iconsetSizes) { await sharp(masterPath) .resize(size, size, { kernel: sharp.kernel.lanczos3, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png({ compressionLevel: 9 }) .toFile(join(iconsetDir, filename)); } } /** * Fill background with solid color (for iOS icons that require no transparency) */ export async function fillBackground( inputPath: string, outputPath: string, color: { r: number; g: number; b: number } = { r: 255, g: 255, b: 255 } ): Promise { const image = sharp(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error("Could not read image dimensions"); } // Create solid background and composite the image on top await sharp({ create: { width: metadata.width, height: metadata.height, channels: 3, background: color, }, }) .composite([ { input: inputPath, blend: "over", }, ]) .png({ compressionLevel: 9 }) .toFile(outputPath); }