Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/imagekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` | Output file | `icon.png` |
| `-m, --model <id>` | OpenRouter model id | `openai/gpt-5.4-image-2` |
| `-s, --system <file>` | System prompt file | `~/.iconkit/system.md` if present |
| `-r, --reference <files...>` | 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
Expand Down
5 changes: 5 additions & 0 deletions apps/imagekit/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/imagekit/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -41,6 +41,7 @@
"prepublishOnly": "bun run build:npm"
},
"dependencies": {
"@openrouter/sdk": "^1.2.84",
"commander": "^13.1.0"
},
"devDependencies": {
Expand Down
115 changes: 115 additions & 0 deletions apps/imagekit/src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<ReturnType<typeof openrouter.chat.send>>;
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)})` : ""}`);
}
21 changes: 20 additions & 1 deletion apps/imagekit/src/commands/web.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];

Expand Down Expand Up @@ -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);
Expand Down
67 changes: 58 additions & 9 deletions apps/imagekit/src/commands/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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++;
Expand All @@ -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++;
Expand Down Expand Up @@ -69,6 +117,7 @@ export function cmdWeb(source: string, opts: WebOptions): void {
source,
"-resize",
"1200x630^",
...(bg ? ["-background", bg, "-flatten"] : []),
"-gravity",
"center",
"-extent",
Expand Down
36 changes: 35 additions & 1 deletion apps/imagekit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,9 +196,42 @@ program
.command("web")
.description("Generate favicon, app icons, and social-card assets")
.option("-o, --output-dir <dir>", "Output directory")
.option(
"-b, --bg <color>",
"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>",
"Fraction (0.1-1) of the canvas content should occupy when --bg is set",
"0.8",
)
.argument("<source>", "Source image file")
.action((source: string, opts: Record<string, unknown>) => {
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("<prompt>", "Text prompt describing the icon")
.option("-o, --output <file>", "Output file", "icon.png")
.option("-m, --model <id>", "OpenRouter model id", "openai/gpt-5.4-image-2")
.option("-s, --system <file>", "System prompt file (default: ~/.iconkit/system.md if present)")
.option("-r, --reference <files...>", "Reference image files")
.action(async (prompt: string, opts: Record<string, unknown>) => {
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
Expand Down
18 changes: 18 additions & 0 deletions apps/imagekit/src/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down