From 8c96396d2dd6a224b872862ecc5cc09727e25437 Mon Sep 17 00:00:00 2001 From: Charles Ponti Date: Fri, 11 Sep 2026 13:15:33 -0700 Subject: [PATCH 1/3] feat(imagekit): add --bg/--safe-zone to web command for maskable icons Source art that bleeds to the canvas edges gets clipped by OS icon masks (rounded square, circle) when used for maskable PWA/Android icons or the iOS apple-touch-icon, and shows transparency as stray white/black. With --bg set, `imagekit web` now trims, resizes content into the safe-zone fraction of the canvas, flattens onto the opaque background, and strips the alpha channel for every square icon output. Behavior without --bg is unchanged. Co-Authored-By: Claude Sonnet 5 --- apps/imagekit/src/commands/web.test.ts | 21 +++++++- apps/imagekit/src/commands/web.ts | 67 ++++++++++++++++++++++---- apps/imagekit/src/index.ts | 36 +++++++++++++- apps/imagekit/src/test-support.ts | 18 +++++++ 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/apps/imagekit/src/commands/web.test.ts b/apps/imagekit/src/commands/web.test.ts index f8870e5..5523aeb 100644 --- a/apps/imagekit/src/commands/web.test.ts +++ b/apps/imagekit/src/commands/web.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import fs from "node:fs"; import path from "node:path"; -import { FIXTURES, dimensions, runIconkit, tempDir } from "../test-support"; +import { FIXTURES, dimensions, hasAlpha, pixelAt, runIconkit, tempDir } from "../test-support"; const temporaryDirs: string[] = []; @@ -32,6 +32,25 @@ describe("iconkit web", () => { expect(fs.existsSync(path.join(output, "apple-touch-icon.png"))).toBe(true); }); + test("--bg flattens onto an opaque background and pads into the safe zone", () => { + const tmp = tempDir("web-bg"); + temporaryDirs.push(tmp); + const source = path.join(FIXTURES, "transparent-rgba.png"); + const output = path.join(tmp, "icons"); + + const result = runIconkit(["web", source, "-o", output, "--bg", "#0a0a0a", "--safe-zone", "0.7"]); + expect(result.exitCode).toBe(0); + + const appleIcon = path.join(output, "apple-touch-icon.png"); + expect(hasAlpha(appleIcon)).toBe(false); + // Corner pixel should be the flattened background, not left transparent/white. + expect(pixelAt(appleIcon, 0, 0)).toBe("10,10,10"); + + const pwaIcon = path.join(output, "icon-512x512.png"); + expect(dimensions(pwaIcon)).toEqual({ width: 512, height: 512 }); + expect(pixelAt(pwaIcon, 0, 0)).toBe("10,10,10"); + }); + test("reports a copy failure without aborting web generation", () => { const tmp = tempDir("web-copy-failure"); temporaryDirs.push(tmp); diff --git a/apps/imagekit/src/commands/web.ts b/apps/imagekit/src/commands/web.ts index 59ee26a..dfb7e33 100644 --- a/apps/imagekit/src/commands/web.ts +++ b/apps/imagekit/src/commands/web.ts @@ -4,6 +4,15 @@ import { run, checkCmd, ensureDir, isRegularFile } from "../utils"; interface WebOptions { outputDir?: string; + /** Background color (any ImageMagick color spec) to flatten onto and pad with. + * Omit to keep the source's own transparency (legacy behavior). Required for + * icons that must be fully opaque (apple-touch-icon) or safely maskable (PWA + * icon-*, android-icon-*) when the source art doesn't already bleed edge to edge. */ + bg?: string; + /** Fraction (0-1) of the canvas the source content should occupy when `bg` is + * set — the rest is padding, keeping content inside the safe zone that OS + * icon masks (circle, squircle, rounded square) crop to. Ignored without `bg`. */ + safeZone?: number; } export function cmdWeb(source: string, opts: WebOptions): void { @@ -17,22 +26,53 @@ export function cmdWeb(source: string, opts: WebOptions): void { const outdir = opts.outputDir ?? path.join(path.dirname(source), "icons"); ensureDir(outdir); + const bg = opts.bg; + const safeZone = Math.min(1, Math.max(0.1, opts.safeZone ?? 0.8)); + console.log(`Generating web icons from ${source} → ${outdir}/`); + if (bg) { + console.log(` background: ${bg}, safe zone: ${Math.round(safeZone * 100)}%`); + } let failed = 0; - const webRun = (size: number, name: string) => { - const output = path.join(outdir, name); - const result = run([ - "magick", - source, + + // Square icon: content resized to fit the safe zone and centered on a + // full-bleed opaque background, so any OS mask (circle/squircle/rounded + // square) never clips it and never reveals transparency as stray white/black. + const paddedArgs = (size: number) => { + const target = Math.max(1, Math.round(size * safeZone)); + return [ + "-trim", + "+repage", "-resize", - `${size}x${size}^`, + `${target}x${target}`, + "-background", + bg as string, + "-flatten", "-gravity", "center", "-extent", `${size}x${size}`, - output, - ]); + "-alpha", + "remove", + "-alpha", + "off", + ]; + }; + + // Legacy behavior: cover-crop to size, keeping source transparency as-is. + const coverArgs = (size: number) => [ + "-resize", + `${size}x${size}^`, + "-gravity", + "center", + "-extent", + `${size}x${size}`, + ]; + + const webRun = (size: number, name: string) => { + const output = path.join(outdir, name); + const result = run(["magick", source, ...(bg ? paddedArgs(size) : coverArgs(size)), output]); if (result.exitCode !== 0 || !fs.existsSync(output)) { console.warn(`Warning: failed to generate ${name}`); failed++; @@ -41,7 +81,15 @@ export function cmdWeb(source: string, opts: WebOptions): void { for (const s of [16, 32, 48, 96]) webRun(s, `favicon-${s}x${s}.png`); const favicon = path.join(outdir, "favicon.ico"); - const faviconResult = run(["magick", source, "-define", "icon:auto-resize=16,32,48", favicon]); + const faviconArgs = bg ? paddedArgs(48) : []; + const faviconResult = run([ + "magick", + source, + ...faviconArgs, + "-define", + "icon:auto-resize=16,32,48", + favicon, + ]); if (faviconResult.exitCode !== 0 || !fs.existsSync(favicon)) { console.warn("Warning: failed to generate favicon.ico"); failed++; @@ -69,6 +117,7 @@ export function cmdWeb(source: string, opts: WebOptions): void { source, "-resize", "1200x630^", + ...(bg ? ["-background", bg, "-flatten"] : []), "-gravity", "center", "-extent", diff --git a/apps/imagekit/src/index.ts b/apps/imagekit/src/index.ts index c285e61..d2acce8 100644 --- a/apps/imagekit/src/index.ts +++ b/apps/imagekit/src/index.ts @@ -12,6 +12,7 @@ import { cmdInstall } from "./commands/install"; import { cmdAnalyze } from "./commands/analyze"; import { cmdFixDates } from "./commands/fix-dates"; import { cmdRename } from "./commands/rename"; +import { cmdGenerate } from "./commands/generate"; import { DATE_PATTERN_HELP } from "./date-patterns"; // Detect invocation name for help text: imagekit is primary, but support iconkit/photokit aliases @@ -195,9 +196,42 @@ program .command("web") .description("Generate favicon, app icons, and social-card assets") .option("-o, --output-dir ", "Output directory") + .option( + "-b, --bg ", + "Flatten onto this opaque background and pad content into the safe zone " + + "(any ImageMagick color, e.g. '#0a0a0a' or 'black') — use for source art " + + "that doesn't already bleed edge to edge, so maskable PWA/Android icons " + + "and the iOS apple-touch-icon don't get clipped or show transparency", + ) + .option( + "--safe-zone ", + "Fraction (0.1-1) of the canvas content should occupy when --bg is set", + "0.8", + ) .argument("", "Source image file") .action((source: string, opts: Record) => { - cmdWeb(source, { outputDir: opts.outputDir as string | undefined }); + cmdWeb(source, { + outputDir: opts.outputDir as string | undefined, + bg: opts.bg as string | undefined, + safeZone: opts.safeZone ? parseFloat(opts.safeZone as string) : undefined, + }); + }); + +program + .command("generate") + .description("Generate an icon from a text prompt via an OpenRouter image model") + .argument("", "Text prompt describing the icon") + .option("-o, --output ", "Output file", "icon.png") + .option("-m, --model ", "OpenRouter model id", "openai/gpt-5.4-image-2") + .option("-s, --system ", "System prompt file (default: ~/.iconkit/system.md if present)") + .option("-r, --reference ", "Reference image files") + .action(async (prompt: string, opts: Record) => { + await cmdGenerate(prompt, { + output: opts.output as string, + model: opts.model as string, + system: opts.system as string | undefined, + reference: opts.reference as string[] | undefined, + }); }); program diff --git a/apps/imagekit/src/test-support.ts b/apps/imagekit/src/test-support.ts index e8a4580..e3f0517 100644 --- a/apps/imagekit/src/test-support.ts +++ b/apps/imagekit/src/test-support.ts @@ -48,6 +48,24 @@ export function metadata(filePath: string, field: string): string { return result.stdout.toString().trim(); } +export function hasAlpha(filePath: string): boolean { + const result = Bun.spawnSync(["sips", "-g", "hasAlpha", filePath]); + return /hasAlpha: yes/.test(result.stdout.toString()); +} + +/** Reads the pixel color at (x, y) as an "r,g,b" string (0-255 channels). */ +export function pixelAt(filePath: string, x: number, y: number): string { + const p = `p{${x},${y}}`; + const result = Bun.spawnSync([ + "magick", + filePath, + "-format", + `%[fx:int(255*${p}.r)],%[fx:int(255*${p}.g)],%[fx:int(255*${p}.b)]`, + "info:", + ]); + return result.stdout.toString().trim(); +} + export function fakeTool(dir: string, name: string, body: string): string { const toolPath = path.join(dir, name); fs.writeFileSync(toolPath, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); From ee5d1f4219a673fe902cf6c9174f88cd18c2d5a0 Mon Sep 17 00:00:00 2001 From: Charles Ponti Date: Fri, 11 Sep 2026 13:26:39 -0700 Subject: [PATCH 2/3] feat(imagekit): add generate command for AI-generated icons Adds `imagekit generate` (OpenRouter image models) plus its README docs and the @openrouter/sdk dependency. This was already wired into index.ts but the command file itself hadn't been committed. Co-Authored-By: Claude Sonnet 5 --- apps/imagekit/README.md | 21 +++++ apps/imagekit/bun.lock | 5 ++ apps/imagekit/package.json | 1 + apps/imagekit/src/commands/generate.ts | 115 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 apps/imagekit/src/commands/generate.ts diff --git a/apps/imagekit/README.md b/apps/imagekit/README.md index e54b280..6d47477 100644 --- a/apps/imagekit/README.md +++ b/apps/imagekit/README.md @@ -165,6 +165,27 @@ imagekit strip logo.jpg Requires `exiftool` or ImageMagick. +### `imagekit generate` — Generate an icon from a text prompt + +```bash +export OPENROUTER_API_KEY=sk-or-... +imagekit generate "an icon for my reading learning app" -o icon.png +imagekit generate "a minimal logomark" -m openai/gpt-5.4-image-2 -r ref1.png ref2.png +``` + +**Options:** + +| Option | Description | Default | +| --- | --- | --- | +| `-o, --output ` | Output file | `icon.png` | +| `-m, --model ` | OpenRouter model id | `openai/gpt-5.4-image-2` | +| `-s, --system ` | System prompt file | `~/.iconkit/system.md` if present | +| `-r, --reference ` | Reference image files to include in the prompt | none | + +Requires `OPENROUTER_API_KEY`. Any OpenRouter image-capable model works — see [openrouter.ai/models](https://openrouter.ai/models?modality=text%2Bimage-%3Etext%2Bimage). + +--- + ### `imagekit info` — Show image dimensions & sizes ```bash diff --git a/apps/imagekit/bun.lock b/apps/imagekit/bun.lock index 66e66db..69ff1e1 100644 --- a/apps/imagekit/bun.lock +++ b/apps/imagekit/bun.lock @@ -5,6 +5,7 @@ "": { "name": "iconkit", "dependencies": { + "@openrouter/sdk": "^1.2.84", "commander": "^13.1.0", }, "devDependencies": { @@ -15,6 +16,8 @@ }, }, "packages": { + "@openrouter/sdk": ["@openrouter/sdk@1.2.84", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-m1kidKxUDqNh2sBDFA5vZUYgYlpMnruuGaOppzPDURJaK6xEA3Mf2Jm2i3VVrroKkPVxyG/1Hyem9ER5foLOXQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], @@ -26,5 +29,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], } } diff --git a/apps/imagekit/package.json b/apps/imagekit/package.json index 81f7bca..c3ff0d6 100644 --- a/apps/imagekit/package.json +++ b/apps/imagekit/package.json @@ -41,6 +41,7 @@ "prepublishOnly": "bun run build:npm" }, "dependencies": { + "@openrouter/sdk": "^1.2.84", "commander": "^13.1.0" }, "devDependencies": { diff --git a/apps/imagekit/src/commands/generate.ts b/apps/imagekit/src/commands/generate.ts new file mode 100644 index 0000000..7a24e8e --- /dev/null +++ b/apps/imagekit/src/commands/generate.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { OpenRouter } from "@openrouter/sdk"; +import { ensureDir, isRegularFile } from "../utils"; + +const DEFAULT_MODEL = "openai/gpt-5.4-image-2"; +const DEFAULT_SYSTEM_PATH = path.join(os.homedir(), ".iconkit", "system.md"); + +interface GenerateOptions { + output?: string; + model?: string; + system?: string; + reference?: string[]; +} + +function loadSystemInstructions(systemPath: string | undefined): string | undefined { + const p = systemPath ?? DEFAULT_SYSTEM_PATH; + return isRegularFile(p) ? fs.readFileSync(p, "utf8") : undefined; +} + +function toDataUrl(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase() || "png"; + const mime = ext === "jpg" ? "jpeg" : ext; + return `data:image/${mime};base64,${fs.readFileSync(filePath).toString("base64")}`; +} + +export async function cmdGenerate(prompt: string, opts: GenerateOptions): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + console.error("Error: OPENROUTER_API_KEY environment variable is not set."); + process.exit(1); + } + + for (const ref of opts.reference ?? []) { + if (!isRegularFile(ref)) { + console.error(`Error: reference file not found: ${ref}`); + process.exit(1); + } + } + + if (opts.system && !isRegularFile(opts.system)) { + console.error(`Error: system prompt file not found: ${opts.system}`); + process.exit(1); + } + + const output = opts.output ?? "icon.png"; + const outDir = path.dirname(path.resolve(output)); + ensureDir(outDir); + + const model = opts.model ?? DEFAULT_MODEL; + const systemInstructions = loadSystemInstructions(opts.system); + const references = (opts.reference ?? []).map((ref) => ({ + type: "image_url" as const, + imageUrl: { url: toDataUrl(ref) }, + })); + + console.log(`Generating icon with ${model}...`); + + const openrouter = new OpenRouter({ apiKey }); + let result: Awaited>; + try { + result = await openrouter.chat.send({ + chatRequest: { + model, + messages: [ + ...(systemInstructions + ? [{ role: "system" as const, content: systemInstructions }] + : []), + { + role: "user" as const, + content: [{ type: "text" as const, text: prompt }, ...references], + }, + ], + modalities: ["image", "text"], + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: OpenRouter request failed: ${message}`); + process.exit(1); + } + + if (!("choices" in result)) { + console.error("Error: unexpected streaming response from OpenRouter."); + process.exit(1); + } + + const image = result.choices[0]?.message.images?.[0]; + if (!image) { + console.error("Error: no image returned by the model."); + process.exit(1); + } + + const match = image.imageUrl.url.match(/^data:image\/(\w+);base64,(.+)$/); + if (!match) { + console.error("Error: unexpected image URL format returned by the model."); + process.exit(1); + } + + const [, subtype, data] = match; + const returnedExt = subtype === "jpeg" ? "jpg" : subtype!; + const requestedExt = path.extname(output).slice(1).toLowerCase(); + const finalOutput = + requestedExt && requestedExt !== returnedExt + ? output.slice(0, -requestedExt.length) + returnedExt + : output; + + fs.writeFileSync(finalOutput, Buffer.from(data!, "base64")); + if (finalOutput !== output) { + console.log(`Note: model returned a ${returnedExt} image, not ${requestedExt}`); + } + const cost = result.usage?.cost; + console.log(`Done — wrote ${finalOutput}${cost != null ? ` ($${cost.toFixed(4)})` : ""}`); +} From c32eb755ecee9ec3614c7a0d6c7776f737372429 Mon Sep 17 00:00:00 2001 From: Charles Ponti Date: Fri, 11 Sep 2026 13:27:21 -0700 Subject: [PATCH 3/3] chore(imagekit): bump to 1.1.0 Co-Authored-By: Claude Sonnet 5 --- apps/imagekit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/imagekit/package.json b/apps/imagekit/package.json index c3ff0d6..736e220 100644 --- a/apps/imagekit/package.json +++ b/apps/imagekit/package.json @@ -1,6 +1,6 @@ { "name": "@ponti-studios/imagekit", - "version": "1.0.0", + "version": "1.1.0", "description": "Image asset toolkit — resize, optimize, crop, convert, strip metadata, analyze EXIF, and generate web icons from the command line", "keywords": [ "avif",