
Art
- 138 installs
- 17.2k repo stars
- Updated August 1, 2026
- danielmiessler/personal_ai_infrastructure
Generate images and visual assets inside a Personal AI Infrastructure agent for decks, UI mocks, marketing creatives, and illustrated documentation without leaving the agent workflow.
About
The art skill adds generative image creation to Daniel Miessler's Personal AI Infrastructure, letting agents produce visuals for presentations, mockups, and content from natural-language prompts within the same orchestrated workflow.
- Multimodal agent output
- On-demand image generation
- Marketing and doc visuals
- PAI skill integration
- Creative asset workflow
Art by the numbers
- 138 all-time installs (skills.sh)
- Ranked #744 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/danielmiessler/personal_ai_infrastructure --skill artAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 138 |
|---|---|
| repo stars | ★ 17.2k |
| Last updated | August 1, 2026 |
| Repository | danielmiessler/personal_ai_infrastructure ↗ |
What it does
Generate images and visual assets inside a Personal AI Infrastructure agent for decks, UI mocks, marketing creatives, and illustrated documentation without leaving the agent workflow.
Files
Art Skill
Complete visual content system for creating illustrations, diagrams, and visual content.
Customization
Before executing, check for user customizations at: ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/
If this directory exists, load and apply:
PREFERENCES.md- Aesthetic preferences, default model, output locationCharacterSpecs.md- Character design specificationsSceneConstruction.md- Scene composition guidelines
These override default behavior. If the directory does not exist, proceed with skill defaults.
🚨 MANDATORY: Voice Notification (REQUIRED BEFORE ANY ACTION)
You MUST send this notification BEFORE doing anything else when this skill is invoked.
1. Send voice notification:
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the WORKFLOWNAME workflow in the Art skill to ACTION"}' \
> /dev/null 2>&1 &2. Output text notification:
Running the **WorkflowName** workflow in the **Art** skill to ACTION...This is not optional. Execute this curl command immediately upon skill invocation.
🚨🚨🚨 CONSTITUTIONAL: ALWAYS RUN A NAMED WORKFLOW. NEVER FREEFORM. 🚨🚨🚨
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ EVERY image generation MUST run through a named workflow ⚠️
⚠️ in this skill's Workflows/ directory. ⚠️
⚠️ NEVER call Tools/Generate.ts with a hand-written prompt ⚠️
⚠️ outside the workflow template — the workflows encode the ⚠️
⚠️ quality standards (TECHNIQUE, palette, composition rules, ⚠️
⚠️ problem-type metaphor, CSE-24 narrative arc) that the models ⚠️
⚠️ consistently fail to honor without them. ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Freeform-prompting Generate.ts directly is FORBIDDEN. It is the documented root cause of every "looks like shit" rejection in the rating history. The Essay / Frameworks / Maps / Comics / etc. workflows exist because the bare model defaults to illustrative-school cartoons; the workflow templates discipline it back to the editorial Lebbeus Woods / Paul Rudolph / charcoal-architectural register the principal actually wants.
Routing rules — pick a workflow FIRST, before writing any prompt:
| Request shape | Required workflow |
|---|---|
| Blog header / editorial essay illustration | `Workflows/Essay.md` — Steps 1–8 in order, no skipping |
| Mermaid diagram | Workflows/Mermaid.md |
| Technical / architecture diagram | Workflows/TechnicalDiagrams.md |
| Framework / 2x2 / matrix | Workflows/Frameworks.md |
| D3 dashboard / chart | Workflows/D3Dashboards.md |
| Taxonomy / hierarchy | Workflows/Taxonomies.md |
| Timeline | Workflows/Timelines.md |
| Comparison | Workflows/Comparisons.md |
| Stat card | Workflows/Stats.md |
| Aphorism / quote card | Workflows/Aphorisms.md |
| Comic panel | Workflows/Comics.md |
| YouTube thumbnail | Workflows/AdHocYouTubeThumbnail.md or Workflows/YouTubeThumbnailChecklist.md |
| PAI pack icon | Workflows/CreatePAIPackIcon.md |
| brand-logo wallpaper | Workflows/LogoWallpaper.md |
| Recipe card | Workflows/RecipeCards.md |
| Map / conceptual map | Workflows/Maps.md |
| Annotated screenshot | Workflows/AnnotatedScreenshots.md |
| Background removal only | Workflows/RemoveBackground.md |
| Embossed logo wallpaper | Workflows/EmbossedLogoWallpaper.md |
| Generic visualization (none of the above fit) | Workflows/Visualize.md |
The ONLY exception: the user explicitly says "freeform" / "skip the workflow" / "just run Generate.ts directly with this prompt: ...". Without that explicit instruction, ALWAYS pick the matching workflow and execute its mandatory steps in order — including the prompt template, the technique block, the palette, the composition rules, and the validation gate.
If no workflow matches the request, stop and surface to the user before generating — propose either (a) the closest existing workflow, (b) using Visualize.md as the generic catch-all, or (c) creating a new workflow first via the CreateSkill skill. Do not improvise.
---
🚨🚨🚨 MANDATORY: Output to Downloads First 🚨🚨🚨
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ ALL GENERATED IMAGES GO TO ~/Downloads/ FIRST ⚠️
⚠️ NEVER output directly to project directories ⚠️
⚠️ User MUST preview in Finder/Preview before use ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━This applies to ALL workflows in this skill.
🚨🚨🚨 MANDATORY: Transparency Rules for Blog Headers 🚨🚨🚨
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ INLINE (body) image → TRANSPARENT (PNG with alpha) ⚠️
⚠️ SOCIAL THUMBNAIL (frontmatter) → SEPIA #EAE9DF (opaque) ⚠️
⚠️ EVERY blog header MUST use --thumbnail (produces both) ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━The blog page background is sepia #EAE9DF. Inline images MUST be transparent PNG so they composite cleanly over the page. Social platforms (X, LinkedIn, RSS readers) do NOT honor transparency — they show white/black bleed-through — so the thumbnail: frontmatter MUST point to the sepia-backed version.
Enforcement when calling `Generate.ts`:
--thumbnailis the ONLY correct flag for blog headers — it implicitly enables--remove-bgand produces BOTHoutput.png(transparent) ANDoutput-thumb.png(#EAE9DF background).- Background removal runs locally via
rembg(no external API). If the model returns JPEG (Nano Banana Pro often does),Generate.tsautomatically renames the output from.jpg→.pngafter rembg processing so the final transparent file is a real PNG with a real alpha channel. If you ever see a.jpglabeled "transparent", that is NOT transparent. - If
rembgisn't installed at~/.local/bin/rembg, the tool fails loudly with install instructions rather than silently producing an opaque image. Install:pipx install rembg(or setREMBG_BINenv var to override the path).
Verification step before declaring an image done (REQUIRED): 1. file ~/Downloads/[name].png → must report PNG image data, ... RGBA (8-bit/color RGBA). If it says JPEG or 8-bit colormap without alpha, transparency failed. 2. file ~/Downloads/[name]-thumb.png → must report PNG image data. The thumb is intentionally opaque with sepia background. 3. Only after both pass: copy to the project directory and wire into the post.
Wiring into the blog post:
- Body inline:
[](/images/blog/[slug]/header.webp)— use the transparent WebP converted from the.png. - Frontmatter:
thumbnail: https://example.com/images/blog/[slug]/header-thumb.png— always the-thumb.png(opaque sepia).
Never reuse the opaque thumbnail for the inline slot. Never reuse the transparent file for the social thumbnail. These are two distinct outputs from one --thumbnail run.
Workflow Routing
Route to the appropriate workflow based on the request.
- Remove background from image →
Workflows/RemoveBackground.md - brand-logo wallpaper with logo integration →
Workflows/LogoWallpaper.md - YouTube thumbnail checklist →
Workflows/YouTubeThumbnailChecklist.md - Blog header or editorial illustration →
Workflows/Essay.md - D3.js interactive chart or dashboard →
Workflows/D3Dashboards.md - Visualization or unsure which format →
Workflows/Visualize.md - Mermaid flowchart or sequence diagram →
Workflows/Mermaid.md - Technical or architecture diagram →
Workflows/TechnicalDiagrams.md - Taxonomy or classification grid →
Workflows/Taxonomies.md - Timeline or chronological progression →
Workflows/Timelines.md - Framework or 2x2 matrix →
Workflows/Frameworks.md - Comparison or X vs Y →
Workflows/Comparisons.md - Annotated screenshot →
Workflows/AnnotatedScreenshots.md - Recipe card or step-by-step →
Workflows/RecipeCards.md - Aphorism or quote card →
Workflows/Aphorisms.md - Conceptual map or territory →
Workflows/Maps.md - Stat card or big number visual →
Workflows/Stats.md - Comic or sequential panels →
Workflows/Comics.md - YouTube thumbnail (with existing assets) →
Workflows/YouTubeThumbnailChecklist.md - Ad-hoc YouTube thumbnail (generate from content) →
Workflows/AdHocYouTubeThumbnail.md - PAI pack icon →
Workflows/CreatePAIPackIcon.md
---
Core Aesthetic
Default: Production-quality concept art style appropriate for editorial and technical content.
User customization defines specific aesthetic preferences including:
- Visual style and influences
- Line treatment and rendering approach
- Color palette and wash technique
- Character design specifications
- Scene composition rules
Load from: ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/PREFERENCES.md
---
Reference Images
User customization may include reference images for consistent style.
Check ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/Art/PREFERENCES.md for:
- Reference image locations
- Style examples by use case
- Character and scene reference guidance
Usage: Before generating images, load relevant user-provided references to match their preferred style.
---
Image Generation
Default model: Check user customization at SKILLCUSTOMIZATIONS/Art/PREFERENCES.md Fallback: nano-banana-pro (Gemini 3 Pro)
Model-Specific Size Requirements
Each model accepts different --size formats. Using the wrong format causes validation errors.
| Model | --size format | Valid values | Default |
|---|---|---|---|
flux | Aspect ratio | 1:1, 16:9, 3:2, 2:3, 3:4, 4:3, 4:5, 5:4, 9:16, 21:9 | 16:9 |
nano-banana | Aspect ratio | 1:1, 16:9, 3:2, 2:3, 3:4, 4:3, 4:5, 5:4, 9:16, 21:9 | 16:9 |
nano-banana-pro | Resolution tier | 1K, 2K, 4K (also accepts --aspect-ratio separately) | 2K |
gpt-image-2 (current) / gpt-image-1.5 (fallback) | Pixel dimensions | 1024x1024, 1536x1024, 1024x1536, 2048x2048, auto | NOTE: gpt-image-1 is deprecated per OpenAI docs — do not use. |
Note: nano-banana-pro uses --size for resolution quality and a separate --aspect-ratio flag for aspect ratio (defaults to 16:9).
🚨 CRITICAL: Always Output to Downloads First
ALL generated images MUST go to `~/Downloads/` first for preview and selection.
Never output directly to a project's public/images/ directory. User needs to review images in Preview before they're used.
Workflow: 1. Generate to ~/Downloads/[descriptive-name].png 2. User reviews in Preview 3. If approved, THEN copy to final destination (e.g., cms/public/images/) 4. Create WebP and thumbnail versions at final destination
# CORRECT - Output to Downloads for preview
bun run ${CLAUDE_SKILL_DIR}/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "[PROMPT]" \
--size 2K \
--aspect-ratio 1:1 \
--thumbnail \
--output ~/Downloads/blog-header-concept.png
# After approval, copy to final location
cp ~/Downloads/blog-header-concept.png ~/LocalProjects/Website/cms/public/images/
cp ~/Downloads/blog-header-concept-thumb.png ~/LocalProjects/Website/cms/public/images/Multiple Reference Images (Character/Style Consistency)
For improved character or style consistency, use multiple --reference-image flags:
# Multiple reference images for better likeness
bun run ${CLAUDE_SKILL_DIR}/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "Person from references at a party..." \
--reference-image face1.jpg \
--reference-image face2.jpg \
--reference-image face3.jpg \
--size 2K \
--aspect-ratio 16:9 \
--output ~/Downloads/character-scene.pngAPI Limits (Gemini):
- Up to 5 human reference images
- Up to 6 object reference images
- Maximum 14 total reference images per request
API keys in: ${PAI_DIR}/.env
Examples
Example 1: Blog header image
User: "create a header for my AI agents post"
→ Invokes ESSAY workflow
→ Generates charcoal sketch prompt
→ Creates image with architectural aesthetic
→ Saves to ~/Downloads/ for preview
→ After approval, copies to public/images/Example 2: Technical architecture diagram
User: "make a diagram showing the SPQA pattern"
→ Invokes TECHNICALDIAGRAMS workflow
→ Creates structured architecture visual
→ Outputs PNG with consistent stylingExample 3: Comparison visualization
User: "visualize humans vs AI decision-making"
→ Invokes COMPARISONS workflow
→ Creates side-by-side visual
→ Charcoal sketch with labeled elementsExample 4: PAI pack icon
User: "create icon for the skill system pack"
→ Invokes CREATEPAIPACKICON workflow
→ Reads workflow from Workflows/CreatePAIPackIcon.md
→ Generates 1K image with --remove-bg for transparency
→ Resizes to 256x256 RGBA PNG
→ Outputs to ~/Downloads/ for preview
→ After approval, copies to ${PROJECTS_DIR}/PAI/Packs/icons/Gotchas
- Always output to ~/Downloads/ first — NEVER directly to project directories. User must preview before use. Multiple past failures from pushing wrong images directly to repos.
- Verify image dimensions match target use case before claiming done. Social media previews, blog headers, and thumbnails have different size requirements. A header that works on the blog may break OG/social previews.
- nano-banana-pro uses `--size` for resolution (1K/2K/4K) and SEPARATE `--aspect-ratio` flag. Don't pass aspect ratio values to
--size. - Reference images: max 5 human, 6 object, 14 total per request (Gemini API limit).
- After generating, use Read tool to visually confirm the image before reporting success. "Generated successfully" means nothing if you haven't looked at it.
- When asked to use a specific image URL or file, use EXACTLY that asset. Don't substitute similar images. Past rating-1 failures from using wrong image assets.
- `--remove-bg` may produce black backgrounds instead of transparency. Always verify transparent PNG output visually before deploying.
Execution Log
After completing any workflow, append a single JSONL entry:
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skill":"Art","workflow":"WORKFLOW_USED","input":"8_WORD_SUMMARY","status":"ok|error","duration_s":SECONDS}' >> ~/.claude/PAI/MEMORY/SKILLS/execution.jsonlReplace WORKFLOW_USED with the workflow executed, 8_WORD_SUMMARY with a brief input description, and SECONDS with approximate wall-clock time. Log status: "error" if the workflow failed.
/**
* discord-bot.ts - Discord Bot Client for Midjourney Integration
*
* Official Discord bot wrapper using discord.js for legitimate interaction
* with Midjourney bot. Handles connection, message sending, monitoring,
* and image downloads.
*
* @see ~/.claude/skills/art/SKILL.md
*/
import {
Client,
GatewayIntentBits,
Message,
TextChannel,
Partials
} from 'discord.js';
import { writeFile } from 'node:fs/promises';
// ============================================================================
// Constants
// ============================================================================
const MIDJOURNEY_BOT_ID = '936929561302675456'; // Official Midjourney bot ID
// ============================================================================
// Types
// ============================================================================
export interface DiscordBotConfig {
token: string;
channelId: string;
}
export interface WaitForResponseOptions {
timeout: number; // in seconds
pollInterval?: number; // in milliseconds
}
// ============================================================================
// Discord Bot Client
// ============================================================================
export class DiscordBotClient {
private client: Client;
private config: DiscordBotConfig;
private connected: boolean = false;
constructor(config: DiscordBotConfig) {
this.config = config;
// Initialize Discord client with required intents
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Message, Partials.Channel],
});
}
/**
* Connect to Discord
*/
async connect(): Promise<void> {
if (this.connected) {
return;
}
return new Promise((resolve, reject) => {
// Set up event handlers
this.client.once('ready', () => {
console.log(`✅ Discord bot connected as ${this.client.user?.tag}`);
this.connected = true;
resolve();
});
this.client.on('error', (error) => {
console.error('❌ Discord client error:', error);
});
// Login with bot token
this.client.login(this.config.token).catch(reject);
});
}
/**
* Send a message to the specified channel
*/
async sendMessage(content: string): Promise<Message> {
if (!this.connected) {
throw new Error('Bot not connected. Call connect() first.');
}
const channel = await this.client.channels.fetch(this.config.channelId);
if (!channel || !channel.isTextBased()) {
throw new Error(`Channel ${this.config.channelId} is not a text channel`);
}
const message = await (channel as TextChannel).send(content);
console.log(`📤 Sent message: ${content}`);
return message;
}
/**
* Wait for Midjourney's response to a prompt
*
* Polls the channel for messages from Midjourney bot that reference
* our initial message. Returns when the response is complete (has image attachments).
*/
async waitForMidjourneyResponse(
initialMessageId: string,
options: WaitForResponseOptions
): Promise<Message> {
const { timeout, pollInterval = 5000 } = options;
const startTime = Date.now();
const timeoutMs = timeout * 1000;
console.log(`⏳ Waiting for Midjourney response (timeout: ${timeout}s)...`);
while (Date.now() - startTime < timeoutMs) {
// Fetch recent messages from channel
const channel = await this.client.channels.fetch(this.config.channelId);
if (!channel || !channel.isTextBased()) {
throw new Error('Channel not found or not text-based');
}
const messages = await (channel as TextChannel).messages.fetch({ limit: 20 });
// Find Midjourney's response to our prompt
for (const [_, message] of messages) {
// Check if message is from Midjourney bot
if (message.author.id !== MIDJOURNEY_BOT_ID) {
continue;
}
// Check if this message references our initial prompt
const referencesOurMessage =
message.reference?.messageId === initialMessageId ||
message.interaction?.id === initialMessageId ||
message.content.includes(initialMessageId);
if (!referencesOurMessage) {
continue;
}
// Check if generation is complete
if (this.isGenerationComplete(message)) {
console.log(`✅ Midjourney generation complete!`);
return message;
} else {
console.log(`⏳ Generation in progress... (${Math.floor((Date.now() - startTime) / 1000)}s)`);
}
}
// Wait before next poll
await this.sleep(pollInterval);
}
throw new Error(`Timeout waiting for Midjourney response after ${timeout}s`);
}
/**
* Check if Midjourney generation is complete
*
* A complete generation has:
* - Image attachments
* - No "Waiting to start" or "%" progress indicators
*/
private isGenerationComplete(message: Message): boolean {
// Must have attachments (the generated image)
if (message.attachments.size === 0) {
return false;
}
// Check for in-progress indicators
const content = message.content.toLowerCase();
const inProgressIndicators = [
'waiting to start',
'(waiting)',
'(0%)',
'(1%)',
'(2%)',
'(3%)',
'(4%)',
'(5%)',
'(6%)',
'(7%)',
'(8%)',
'(9%)',
// Continue patterns for progress
'%)',
];
for (const indicator of inProgressIndicators) {
if (content.includes(indicator)) {
return false;
}
}
return true;
}
/**
* Download image from URL to local path
*/
async downloadImage(url: string, outputPath: string): Promise<void> {
console.log(`📥 Downloading image from ${url}...`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await writeFile(outputPath, buffer);
console.log(`✅ Image saved to ${outputPath}`);
}
/**
* Disconnect from Discord
*/
async disconnect(): Promise<void> {
if (!this.connected) {
return;
}
await this.client.destroy();
this.connected = false;
console.log('👋 Discord bot disconnected');
}
/**
* Get the first image attachment URL from a message
*/
getImageUrl(message: Message): string | null {
if (message.attachments.size === 0) {
return null;
}
// Get first attachment
const attachment = message.attachments.first();
if (!attachment) {
return null;
}
// Verify it's an image
const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
const isImage = imageExtensions.some(ext =>
attachment.url.toLowerCase().includes(ext)
);
if (!isImage) {
return null;
}
return attachment.url;
}
/**
* Sleep utility
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
/**
* midjourney-client.ts - Midjourney Interaction Client
*
* High-level client for interacting with Midjourney bot through Discord.
* Handles prompt formatting, command submission, response parsing,
* and error detection.
*
* @see ~/.claude/skills/art/SKILL.md
*/
import { DiscordBotClient } from './discord-bot.js';
import { Message } from 'discord.js';
// ============================================================================
// Types
// ============================================================================
export interface MidjourneyOptions {
prompt: string;
aspectRatio?: string;
version?: string;
stylize?: number;
quality?: number;
chaos?: number;
weird?: number;
tile?: boolean;
timeout?: number; // in seconds
}
export interface MidjourneyResult {
imageUrl: string;
prompt: string;
messageId: string;
}
export type MidjourneyErrorType =
| 'content_policy'
| 'timeout'
| 'connection'
| 'invalid_params'
| 'generation_failed'
| 'no_image';
export class MidjourneyError extends Error {
constructor(
public type: MidjourneyErrorType,
message: string,
public originalPrompt?: string,
public suggestion?: string
) {
super(message);
this.name = 'MidjourneyError';
}
}
// ============================================================================
// Midjourney Client
// ============================================================================
export class MidjourneyClient {
private discordBot: DiscordBotClient;
constructor(discordBot: DiscordBotClient) {
this.discordBot = discordBot;
}
/**
* Generate image with Midjourney
*
* Submits prompt, waits for generation, and returns image URL
*/
async generateImage(options: MidjourneyOptions): Promise<MidjourneyResult> {
const {
prompt,
aspectRatio = '16:9',
version = '6.1',
stylize = 100,
quality = 1,
chaos,
weird,
tile = false,
timeout = 120,
} = options;
// Format the Midjourney prompt
const formattedPrompt = this.formatPrompt({
prompt,
aspectRatio,
version,
stylize,
quality,
chaos,
weird,
tile,
});
console.log(`🎨 Submitting to Midjourney: ${formattedPrompt}`);
// Send the /imagine command
const initialMessage = await this.discordBot.sendMessage(`/imagine prompt: ${formattedPrompt}`);
// Wait for Midjourney to complete generation
let responseMessage: Message;
try {
responseMessage = await this.discordBot.waitForMidjourneyResponse(initialMessage.id, {
timeout,
pollInterval: 5000,
});
} catch (error) {
if (error instanceof Error && error.message.includes('Timeout')) {
throw new MidjourneyError(
'timeout',
`Generation timed out after ${timeout}s. The image may still be processing in Discord.`,
formattedPrompt,
'Try checking Discord manually or increasing the timeout value.'
);
}
throw error;
}
// Check for errors in response
this.detectErrors(responseMessage, formattedPrompt);
// Extract image URL
const imageUrl = this.discordBot.getImageUrl(responseMessage);
if (!imageUrl) {
throw new MidjourneyError(
'no_image',
'No image found in Midjourney response',
formattedPrompt,
'The generation may have failed. Check Discord for error messages.'
);
}
return {
imageUrl,
prompt: formattedPrompt,
messageId: responseMessage.id,
};
}
/**
* Format Midjourney prompt with parameters
*
* Converts structured options into Midjourney command syntax
*/
private formatPrompt(options: {
prompt: string;
aspectRatio: string;
version: string;
stylize: number;
quality: number;
chaos?: number;
weird?: number;
tile: boolean;
}): string {
const { prompt, aspectRatio, version, stylize, quality, chaos, weird, tile } = options;
let formattedPrompt = prompt;
// Add aspect ratio
formattedPrompt += ` --ar ${aspectRatio}`;
// Add version
formattedPrompt += ` --v ${version}`;
// Add stylize (default is 100, only add if different)
if (stylize !== 100) {
formattedPrompt += ` --s ${stylize}`;
}
// Add quality (default is 1, only add if different)
if (quality !== 1) {
formattedPrompt += ` --q ${quality}`;
}
// Add optional parameters
if (chaos !== undefined) {
formattedPrompt += ` --chaos ${chaos}`;
}
if (weird !== undefined) {
formattedPrompt += ` --weird ${weird}`;
}
if (tile) {
formattedPrompt += ` --tile`;
}
return formattedPrompt;
}
/**
* Detect errors in Midjourney response
*/
private detectErrors(message: Message, originalPrompt: string): void {
const content = message.content.toLowerCase();
// Content policy violations
const contentPolicyIndicators = [
'banned prompt',
'content policy',
'violates our community standards',
'inappropriate content',
'against our terms',
];
for (const indicator of contentPolicyIndicators) {
if (content.includes(indicator)) {
throw new MidjourneyError(
'content_policy',
'Prompt violates Midjourney content policy',
originalPrompt,
'Try rephrasing your prompt to avoid potentially sensitive content.'
);
}
}
// Invalid parameters
const invalidParamIndicators = [
'invalid parameter',
'unknown parameter',
'invalid aspect ratio',
'invalid version',
];
for (const indicator of invalidParamIndicators) {
if (content.includes(indicator)) {
throw new MidjourneyError(
'invalid_params',
'Invalid Midjourney parameters',
originalPrompt,
'Check your aspect ratio, version, and other parameter values.'
);
}
}
// Generation failures
const failureIndicators = [
'failed to generate',
'generation failed',
'error generating',
'something went wrong',
];
for (const indicator of failureIndicators) {
if (content.includes(indicator)) {
throw new MidjourneyError(
'generation_failed',
'Midjourney generation failed',
originalPrompt,
'Try again or check Discord for more details.'
);
}
}
}
/**
* Parse Midjourney response to extract metadata
*/
parseResponse(message: Message): {
prompt: string;
parameters: Record<string, string>;
} {
const content = message.content;
// Extract prompt (usually before the first --)
const promptMatch = content.match(/^(.+?)(?:\s+--|\s*$)/);
const prompt = promptMatch ? promptMatch[1].trim() : content;
// Extract parameters
const parameters: Record<string, string> = {};
const paramRegex = /--(\w+)\s+([^\s-]+)/g;
let match;
while ((match = paramRegex.exec(content)) !== null) {
parameters[match[1]] = match[2];
}
return { prompt, parameters };
}
/**
* Validate Midjourney options before submission
*/
static validateOptions(options: MidjourneyOptions): void {
// Validate aspect ratio
const validAspectRatios = [
'1:1', '16:9', '9:16', '2:3', '3:2', '4:5', '5:4', '7:4', '4:7',
'21:9', '9:21', '3:4', '4:3'
];
if (options.aspectRatio && !validAspectRatios.includes(options.aspectRatio)) {
throw new Error(
`Invalid aspect ratio: ${options.aspectRatio}. Valid ratios: ${validAspectRatios.join(', ')}`
);
}
// Validate version
const validVersions = ['6.1', '6', '5.2', '5.1', '5', 'niji', 'niji 6'];
if (options.version && !validVersions.includes(options.version)) {
throw new Error(
`Invalid version: ${options.version}. Valid versions: ${validVersions.join(', ')}`
);
}
// Validate stylize (0-1000)
if (options.stylize !== undefined && (options.stylize < 0 || options.stylize > 1000)) {
throw new Error('Stylize must be between 0 and 1000');
}
// Validate quality
const validQualities = [0.25, 0.5, 1, 2];
if (options.quality !== undefined && !validQualities.includes(options.quality)) {
throw new Error('Quality must be 0.25, 0.5, 1, or 2');
}
// Validate chaos (0-100)
if (options.chaos !== undefined && (options.chaos < 0 || options.chaos > 100)) {
throw new Error('Chaos must be between 0 and 100');
}
// Validate weird (0-3000)
if (options.weird !== undefined && (options.weird < 0 || options.weird > 3000)) {
throw new Error('Weird must be between 0 and 3000');
}
// Validate timeout
if (options.timeout !== undefined && options.timeout < 30) {
throw new Error('Timeout must be at least 30 seconds');
}
}
}
../../CLAUDE.md# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "tools",
"dependencies": {
"@google/genai": "^1.40.0",
"openai": "^6.18.0",
"replicate": "^1.4.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@google/genai": ["@google/genai@1.40.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"openai": ["openai@6.18.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"replicate": ["replicate@1.4.0", "", { "optionalDependencies": { "readable-stream": ">=4.0.0" } }, "sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
Default to using Bun instead of Node.js.
- Use
bun <file>instead ofnode <file>orts-node <file> - Use
bun testinstead ofjestorvitest - Use
bun build <file.html|file.ts|file.css>instead ofwebpackoresbuild - Use
bun installinstead ofnpm installoryarn installorpnpm install - Use
bun run <script>instead ofnpm run <script>oryarn run <script>orpnpm run <script> - Use
bunx <package> <command>instead ofnpx <package> <command> - Bun automatically loads .env, so don't use dotenv.
APIs
Bun.serve()supports WebSockets, HTTPS, and routes. Don't useexpress.bun:sqlitefor SQLite. Don't usebetter-sqlite3.Bun.redisfor Redis. Don't useioredis.Bun.sqlfor Postgres. Don't usepgorpostgres.js.WebSocketis built-in. Don't usews.- Prefer
Bun.fileovernode:fs's readFile/writeFile - Bun.$
lsinstead of execa.
Testing
Use bun test to run tests.
```ts#index.test.ts import { test, expect } from "bun:test";
test("hello world", () => { expect(1).toBe(1); });
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
import index from "./index.html"
Bun.serve({ routes: { "/": index, "/api/users/:id": { GET: (req) => { return new Response(JSON.stringify({ id: req.params.id })); }, }, }, // optional websocket support websocket: { open: (ws) => { ws.send("Hello, world!"); }, message: (ws, message) => { ws.send(message); }, close: (ws) => { // handle close } }, development: { hmr: true, console: true, } })
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
<html> <body> <h1>Hello, world!</h1> <script type="module" src="./frontend.tsx"></script> </body> </html>
With the following `frontend.tsx`:
import React from "react"; import { createRoot } from "react-dom/client";
// import .css files directly and it works import './index.css';
const root = createRoot(document.body);
export default function Frontend() { return <h1>Hello, world!</h1>; }
root.render(<Frontend />);
Then, run index.ts
bun --hot ./index.ts
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
#!/usr/bin/env bun
/**
* ComposeThumbnail - YouTube Thumbnail Composition CLI
*
* Composites background, headshot, and text into a YouTube thumbnail.
* Uses ImageMagick for all composition operations.
*
* Features:
* - Dynamic headshot positioning (left, center, right)
* - Solid black backdrop boxes behind text for readability
* - Full-height headshot that dominates the frame
* - Colored border (Tokyo Night purple default)
*/
import { spawn } from "node:child_process";
import { existsSync, unlinkSync } from "node:fs";
import { resolve, dirname } from "node:path";
// ============================================================================
// Types
// ============================================================================
interface CLIArgs {
background: string;
headshot: string;
title: string;
subtitle: string;
output: string;
titleColor?: string;
subtitleColor?: string;
borderColor?: string;
font?: string;
headshotPosition?: "left" | "center" | "right";
}
// ============================================================================
// Configuration
// ============================================================================
const DEFAULTS = {
titleColor: "#7dcfff", // Tokyo Night cyan - VIBRANT by default
subtitleColor: "#FFFFFF", // White text for contrast
borderColor: "#bb9af7", // Tokyo Night Vivid Purple
font: "Helvetica-Bold", // System font that actually exists
headshotPosition: "left" as const,
output: `${process.env.HOME}/Downloads/yt-thumbnail-${Date.now()}.png`,
};
const LAYOUT = {
width: 1280,
height: 720,
borderWidth: 16,
// TEXT - BILLBOARD STYLE (large, bold, dominant)
titleSize: 100, // DOMINANT - fills the space
subtitleSize: 50, // Proportionally sized, still readable
titleStroke: 4, // Bold outline for visibility
subtitleStroke: 3, // Visible outline
textPadding: 6,
textBoxPadding: 20,
// Safe zones - headshot and text never overlap
headshotMaxWidth: 0.40, // Headshot takes max 40% width
textZoneWidth: 0.55, // Text zone is 55% width (FILLS the space)
textZoneGap: 0.05, // 5% gap between zones
};
// Color presets for text (Tokyo Night palette + extras)
const COLOR_PRESETS: Record<string, string> = {
white: "#FFFFFF",
cyan: "#7dcfff",
purple: "#bb9af7",
blue: "#7aa2f7",
magenta: "#ff007c",
yellow: "#e0af68",
green: "#9ece6a",
orange: "#ff9e64",
red: "#f7768e",
};
function resolveColor(color: string): string {
// If it's a preset name, return the hex value
const preset = COLOR_PRESETS[color.toLowerCase()];
if (preset) return preset;
// Otherwise assume it's already a hex color
return color;
}
// ============================================================================
// Error Handling
// ============================================================================
class CLIError extends Error {
constructor(message: string, public exitCode: number = 1) {
super(message);
this.name = "CLIError";
}
}
// ============================================================================
// Helpers
// ============================================================================
function printHelp(): void {
console.log(`
ComposeThumbnail - YouTube Thumbnail Composition CLI
USAGE:
bun ~/.claude/skills/Art/Tools/ComposeThumbnail.ts [OPTIONS]
REQUIRED:
--background <path> Background image (dramatic tech art)
--headshot <path> Headshot image (transparent background)
--title <text> Title text (max 6 words, auto-capitalized)
--subtitle <text> Subtitle text (max 12 words, auto-capitalized)
OPTIONAL:
--output <path> Output path (default: ~/Downloads/yt-thumbnail-{timestamp}.png)
--position <pos> Headshot position: left, center, right (default: left)
--font <name> Font name (default: Helvetica-Bold)
--title-color <hex> Title color (default: #FFFFFF)
--subtitle-color <hex> Subtitle color (default: #FFFFFF)
--border-color <hex> Border color (default: #bb9af7 Tokyo Night Purple)
--help, -h Show this help message
EXAMPLE:
bun ~/.claude/skills/Art/Tools/ComposeThumbnail.ts \\
--background ~/Downloads/tech-background.png \\
--headshot ~/Downloads/headshot-nobg.png \\
--title "AI AGENTS KILLING SOFTWARE" \\
--subtitle "WHY TRADITIONAL DEVELOPMENT IS DEAD" \\
--position left \\
--output ~/Downloads/thumbnail.png
LAYOUT:
Canvas: 1280x720 px
Border: 16px colored border (Tokyo Night purple)
Headshot: Full height inside border, positioned left/center/right
Text: White text with minimal black backdrop boxes
`);
}
function parseArgs(args: string[]): CLIArgs {
const result: Partial<CLIArgs> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case "--help":
case "-h":
printHelp();
process.exit(0);
case "--background":
result.background = next;
i++;
break;
case "--headshot":
result.headshot = next;
i++;
break;
case "--title":
result.title = next;
i++;
break;
case "--subtitle":
result.subtitle = next;
i++;
break;
case "--output":
result.output = next;
i++;
break;
case "--position":
if (next === "left" || next === "center" || next === "right") {
result.headshotPosition = next;
}
i++;
break;
case "--title-color":
result.titleColor = next;
i++;
break;
case "--subtitle-color":
result.subtitleColor = next;
i++;
break;
case "--border-color":
result.borderColor = next;
i++;
break;
case "--font":
result.font = next;
i++;
break;
}
}
// Validate required args
if (!result.background) throw new CLIError("--background is required");
if (!result.headshot) throw new CLIError("--headshot is required");
if (!result.title) throw new CLIError("--title is required");
if (!result.subtitle) throw new CLIError("--subtitle is required");
// Validate files exist
if (!existsSync(result.background)) {
throw new CLIError(`Background file not found: ${result.background}`);
}
if (!existsSync(result.headshot)) {
throw new CLIError(`Headshot file not found: ${result.headshot}`);
}
return {
background: resolve(result.background),
headshot: resolve(result.headshot),
title: result.title.toUpperCase(),
subtitle: result.subtitle.toUpperCase(),
output: result.output ? resolve(result.output) : DEFAULTS.output,
titleColor: result.titleColor || DEFAULTS.titleColor,
subtitleColor: result.subtitleColor || DEFAULTS.subtitleColor,
borderColor: result.borderColor || DEFAULTS.borderColor,
font: result.font || DEFAULTS.font,
headshotPosition: result.headshotPosition || DEFAULTS.headshotPosition,
};
}
async function runCommand(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => (stdout += data.toString()));
proc.stderr.on("data", (data) => (stderr += data.toString()));
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new CLIError(`Command failed: ${cmd} ${args.join(" ")}\n${stderr}`, code || 1));
}
});
});
}
// ============================================================================
// Headshot Processing
// ============================================================================
/**
* Crop headshot to FACE ONLY - removes shoulders/body and zooms into face.
* This ensures the face dominates the frame without clipped body parts.
*/
async function cropToFaceOnly(headshotPath: string, outputPath: string): Promise<void> {
// Get original dimensions
const dimensions = await runCommand("magick", [
"identify", "-format", "%wx%h", headshotPath
]);
const [width, height] = dimensions.trim().split("x").map(Number);
// Crop bottom 25% (removes shoulders/body) and zoom 135% into face
await runCommand("magick", [
headshotPath,
"-gravity", "north", // Anchor to top (face area)
"-crop", `100%x75%+0+0`, // Crop bottom 25% (shoulders/body)
"+repage",
"-resize", "135%", // Zoom into face
"-gravity", "center",
"-extent", `${width}x${height}`, // Restore original dimensions
outputPath,
]);
}
// ============================================================================
// Main Composition
// ============================================================================
async function composeThumbnail(args: CLIArgs): Promise<void> {
const outputDir = dirname(args.output);
const timestamp = Date.now();
// Intermediate files
const resizedBg = `${outputDir}/.yt-bg-${timestamp}.png`;
const croppedHeadshot = `${outputDir}/.yt-cropped-${timestamp}.png`;
const withHeadshot = `${outputDir}/.yt-headshot-${timestamp}.png`;
const withText = `${outputDir}/.yt-text-${timestamp}.png`;
const intermediates = [resizedBg, croppedHeadshot, withHeadshot, withText];
try {
console.log("🎨 Composing YouTube thumbnail...");
// Step 1: Resize background to exact dimensions
console.log(" 📐 Resizing background to 1280x720...");
await runCommand("magick", [
args.background,
"-resize", `${LAYOUT.width}x${LAYOUT.height}^`,
"-gravity", "center",
"-extent", `${LAYOUT.width}x${LAYOUT.height}`,
resizedBg,
]);
// Step 2: Crop headshot to FACE ONLY (remove shoulders/body)
console.log(` ✂️ Cropping headshot to face only...`);
await cropToFaceOnly(args.headshot, croppedHeadshot);
// Step 3: Composite headshot based on position
console.log(` 👤 Adding headshot (${args.headshotPosition})...`);
// Calculate headshot height - FULL HEIGHT inside border
// Face should fill ~95% of vertical space inside the border
const headshotHeight = LAYOUT.height - (LAYOUT.borderWidth * 2); // 688px
// Determine gravity and offset based on position
let gravity: string;
let geometryOffset: string;
switch (args.headshotPosition) {
case "left":
gravity = "west";
geometryOffset = "+20+0";
break;
case "center":
gravity = "center";
geometryOffset = "+0+0";
break;
case "right":
gravity = "east";
geometryOffset = "+20+0";
break;
default:
gravity = "west";
geometryOffset = "+20+0";
}
await runCommand("magick", [
resizedBg,
"(",
croppedHeadshot, // Use cropped headshot (face only)
"-resize", `x${headshotHeight}`,
")",
"-gravity", gravity,
"-geometry", geometryOffset,
"-composite",
withHeadshot,
]);
// Step 4: Add text with stroke outline
console.log(" 📝 Adding text with stroke outlines...");
// For left/right positions: create combined text block, center in available region
// For center position: separate title (top) and subtitle (bottom)
// Resolve colors (support preset names like "cyan" or hex like "#ff007c")
const titleColorResolved = resolveColor(args.titleColor!);
const subtitleColorResolved = resolveColor(args.subtitleColor!);
if (args.headshotPosition === "center") {
// CENTER: Title at top, subtitle at bottom (outside headshot zone)
const titleWithStroke = `${outputDir}/.yt-title-${timestamp}.png`;
const subtitleWithStroke = `${outputDir}/.yt-subtitle-${timestamp}.png`;
intermediates.push(titleWithStroke, subtitleWithStroke);
// Create title (WIDE canvas for 100pt BILLBOARD text)
await runCommand("magick", [
"-size", "1400x200",
"xc:transparent",
"-font", args.font!,
"-pointsize", String(LAYOUT.titleSize),
"-gravity", "center",
"-stroke", "#000000", "-strokewidth", String(LAYOUT.titleStroke), "-fill", "none",
"-annotate", "+0+0", args.title,
"-stroke", "none", "-fill", titleColorResolved,
"-annotate", "+0+0", args.title,
"-trim", "+repage",
titleWithStroke,
]);
// Create subtitle (WIDE canvas for 50pt BILLBOARD text)
await runCommand("magick", [
"-size", "1400x120",
"xc:transparent",
"-font", args.font!,
"-pointsize", String(LAYOUT.subtitleSize),
"-gravity", "center",
"-stroke", "#000000", "-strokewidth", String(LAYOUT.subtitleStroke), "-fill", "none",
"-annotate", "+0+0", args.subtitle,
"-stroke", "none", "-fill", subtitleColorResolved,
"-annotate", "+0+0", args.subtitle,
"-trim", "+repage",
subtitleWithStroke,
]);
// Composite title at top (inside border, above headshot zone)
const withTitle = `${outputDir}/.yt-with-title-${timestamp}.png`;
intermediates.push(withTitle);
await runCommand("magick", [
withHeadshot,
titleWithStroke,
"-gravity", "north",
"-geometry", "+0+25",
"-composite",
withTitle,
]);
// Composite subtitle at bottom (inside border, below headshot zone)
await runCommand("magick", [
withTitle,
subtitleWithStroke,
"-gravity", "south",
"-geometry", "+0+25",
"-composite",
withText,
]);
} else {
// LEFT or RIGHT: Create text in safe zone (NEVER overlap headshot)
// Calculate text zone center (opposite side from headshot)
// Position at 62% or 38% of canvas width with generous margins
// Account for 16px border + compression during final resize
const textZoneCenter = args.headshotPosition === "left"
? Math.round(LAYOUT.width * 0.62) // 794px - text on right with generous margin
: Math.round(LAYOUT.width * 0.38); // 486px - text on left with generous margin
// Create title and subtitle with BILLBOARD sizing
const titleImg = `${outputDir}/.yt-title-${timestamp}.png`;
const subtitleImg = `${outputDir}/.yt-subtitle-${timestamp}.png`;
intermediates.push(titleImg, subtitleImg);
// Create title - 100pt BILLBOARD text on WIDE canvas (prevents cutoff)
await runCommand("magick", [
"-size", "1400x200",
"xc:transparent",
"-font", args.font!,
"-gravity", "center",
"-pointsize", String(LAYOUT.titleSize),
"-stroke", "#000000", "-strokewidth", String(LAYOUT.titleStroke), "-fill", "none",
"-annotate", "+0+0", args.title,
"-stroke", "none", "-fill", titleColorResolved,
"-annotate", "+0+0", args.title,
"-trim", "+repage",
titleImg,
]);
// Create subtitle - 50pt BILLBOARD text on WIDE canvas (prevents cutoff)
await runCommand("magick", [
"-size", "1400x120",
"xc:transparent",
"-font", args.font!,
"-gravity", "center",
"-pointsize", String(LAYOUT.subtitleSize),
"-stroke", "#000000", "-strokewidth", String(LAYOUT.subtitleStroke), "-fill", "none",
"-annotate", "+0+0", args.subtitle,
"-stroke", "none", "-fill", subtitleColorResolved,
"-annotate", "+0+0", args.subtitle,
"-trim", "+repage",
subtitleImg,
]);
// Position text at center of text zone using absolute coordinates
// Composite title above center
const withTitle = `${outputDir}/.yt-with-title-${timestamp}.png`;
intermediates.push(withTitle);
// Calculate absolute X position for centering in text zone
// We'll use page geometry to position at exact coordinates
// Larger fonts need more vertical spread
const titleY = Math.round(LAYOUT.height / 2) - 80; // Above center (for 100pt)
const subtitleY = Math.round(LAYOUT.height / 2) + 50; // Below center (for 50pt)
await runCommand("magick", [
withHeadshot,
titleImg,
"-gravity", "north",
"-geometry", `+${textZoneCenter - LAYOUT.width/2}+${titleY}`,
"-composite",
withTitle,
]);
// Composite subtitle below title
await runCommand("magick", [
withTitle,
subtitleImg,
"-gravity", "north",
"-geometry", `+${textZoneCenter - LAYOUT.width/2}+${subtitleY}`,
"-composite",
withText,
]);
}
// Step 5: Add colored border
console.log(" 🖼️ Adding border...");
await runCommand("magick", [
withText,
"-bordercolor", args.borderColor!,
"-border", String(LAYOUT.borderWidth),
"-resize", `${LAYOUT.width}x${LAYOUT.height}!`,
args.output,
]);
console.log(`✅ Thumbnail saved to ${args.output}`);
// Verify dimensions
const identify = await runCommand("magick", ["identify", "-format", "%wx%h", args.output]);
console.log(` 📏 Dimensions: ${identify.trim()}`);
} finally {
// Cleanup intermediate files
for (const file of intermediates) {
try {
if (existsSync(file)) {
unlinkSync(file);
}
} catch {
// Ignore cleanup errors
}
}
}
}
// ============================================================================
// Main
// ============================================================================
async function main(): Promise<void> {
try {
const args = parseArgs(process.argv.slice(2));
await composeThumbnail(args);
} catch (error) {
if (error instanceof CLIError) {
console.error(`❌ Error: ${error.message}`);
process.exit(error.exitCode);
}
throw error;
}
}
main();
#!/usr/bin/env bun
/**
* FillFrame — deterministic full-frame enforcement for Art-skill outputs.
*
* Problem this solves: image models persistently leave 10-30% empty margins
* around the subject, even when the prompt explicitly demands edge-to-edge
* composition. The Essay workflow's MARGIN CHECK was a manual checkbox; this
* tool makes it a deterministic post-process that fires on every Essay/header
* generation.
*
* What it does:
* 1. Loads the generated image
* 2. Detects the subject bounding box (non-background pixels via fuzz match
* to corner samples — works on transparent PNGs and solid-color grounds)
* 3. Crops to that bbox plus a configurable safety inset (default 2%)
* 4. Resizes back to the target square dimension so the subject now fills
* the frame edge-to-edge
* 5. Reports margin metrics before/after so the workflow knows what shifted
*
* Usage:
* bun ~/.claude/skills/Art/Tools/FillFrame.ts <input.png> <output.png>
* [--target-size 1024]
* [--bg-color "#000000"|auto] # color to treat as background; "auto" samples corners
* [--fuzz 8] # tolerance percentage for bg detection
* [--inset 2] # safety padding percentage around detected bbox
* [--max-margin 5] # fail if any edge has more than N% empty after fill
* [--report-only] # just print metrics, do not write
*
* Exit codes:
* 0 — image was already full-frame, OR was successfully refilled
* 1 — refill failed (bbox detection inconclusive, or post-fill margin still > max-margin)
*/
import { $ } from "bun";
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
interface Args {
input: string;
output: string;
targetSize: number;
bgColor: string; // hex or "auto"
fuzz: number;
inset: number;
maxMargin: number;
reportOnly: boolean;
}
function parseArgs(): Args {
const argv = Bun.argv.slice(2);
if (argv.length < 2) {
console.error("usage: bun FillFrame.ts <input> <output> [--target-size N] [--bg-color HEX|auto] [--fuzz N] [--inset N] [--max-margin N] [--report-only]");
process.exit(2);
}
const flag = (name: string, fallback: string): string => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && argv[i + 1] ? argv[i + 1]! : fallback;
};
const has = (name: string): boolean => argv.includes(`--${name}`);
return {
input: resolve(argv[0]!),
output: resolve(argv[1]!),
targetSize: parseInt(flag("target-size", "1024"), 10),
bgColor: flag("bg-color", "auto"),
fuzz: parseInt(flag("fuzz", "8"), 10),
inset: parseInt(flag("inset", "2"), 10),
maxMargin: parseInt(flag("max-margin", "5"), 10),
reportOnly: has("report-only"),
};
}
async function detectBgColor(input: string): Promise<string> {
// Sample five points (4 corners + slight inset to avoid jpeg edge artifacts)
const samples = await Promise.all(
["10,10", "1014,10", "10,1014", "1014,1014", "20,512"].map(async (p) => {
const out = await $`magick ${input} -format ${`%[pixel:p{${p}}]`} info:`.quiet().text();
return out.trim();
})
);
// If 3+ samples agree (within 5 fuzz), use that color; else fall back to first corner
const counts = new Map<string, number>();
for (const s of samples) counts.set(s, (counts.get(s) ?? 0) + 1);
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
return sorted[0]![0];
}
interface Geometry { width: number; height: number; offsetX: number; offsetY: number; }
async function getBbox(input: string, bgColor: string, fuzzPct: number): Promise<Geometry> {
// Use -trim to find content bbox; fuzz tolerates near-bg pixels
// -format "%@" prints the bounding box as "WxH+X+Y"
const result = await $`magick ${input} -bordercolor ${bgColor} -border 1 -fuzz ${fuzzPct}% -trim -format ${"%@"} info:`.quiet().text();
const m = result.trim().match(/^(\d+)x(\d+)\+(\d+)\+(\d+)/);
if (!m) throw new Error(`could not parse bbox from "${result}"`);
return {
width: parseInt(m[1]!, 10),
height: parseInt(m[2]!, 10),
offsetX: parseInt(m[3]!, 10) - 1, // subtract the +1 border we added
offsetY: parseInt(m[4]!, 10) - 1,
};
}
async function getDimensions(input: string): Promise<{ w: number; h: number }> {
const result = await $`magick ${input} -format ${"%w %h"} info:`.quiet().text();
const [w, h] = result.trim().split(/\s+/).map((n) => parseInt(n!, 10));
return { w: w!, h: h! };
}
function marginPercents(bbox: Geometry, full: { w: number; h: number }) {
return {
top: (bbox.offsetY / full.h) * 100,
bottom: ((full.h - (bbox.offsetY + bbox.height)) / full.h) * 100,
left: (bbox.offsetX / full.w) * 100,
right: ((full.w - (bbox.offsetX + bbox.width)) / full.w) * 100,
};
}
async function main() {
const args = parseArgs();
if (!existsSync(args.input)) {
console.error(`input not found: ${args.input}`);
process.exit(2);
}
const full = await getDimensions(args.input);
const bgColor = args.bgColor === "auto" ? await detectBgColor(args.input) : args.bgColor;
console.log(`📐 input: ${full.w}x${full.h} bg-color: ${bgColor} fuzz: ${args.fuzz}%`);
let bbox: Geometry;
try {
bbox = await getBbox(args.input, bgColor, args.fuzz);
} catch (e) {
console.error(`❌ bbox detection failed: ${(e as Error).message}`);
console.error(` try a different --bg-color or larger --fuzz`);
process.exit(1);
}
const before = marginPercents(bbox, full);
console.log(`📏 before: bbox ${bbox.width}x${bbox.height}+${bbox.offsetX}+${bbox.offsetY} margins T:${before.top.toFixed(1)}% B:${before.bottom.toFixed(1)}% L:${before.left.toFixed(1)}% R:${before.right.toFixed(1)}%`);
const maxMarginBefore = Math.max(before.top, before.bottom, before.left, before.right);
if (maxMarginBefore <= args.maxMargin) {
console.log(`✅ already full-frame (max margin ${maxMarginBefore.toFixed(1)}% ≤ ${args.maxMargin}%)`);
if (!args.reportOnly && args.input !== args.output) {
await $`cp ${args.input} ${args.output}`.quiet();
}
process.exit(0);
}
if (args.reportOnly) {
console.log(`⚠️ margins exceed ${args.maxMargin}% threshold — refill needed`);
process.exit(1);
}
// Apply safety inset (negative — shrink the bbox by inset% so we don't crop subject edges)
const insetPx = Math.round((Math.min(full.w, full.h) * args.inset) / 100);
const cropX = Math.max(0, bbox.offsetX - insetPx);
const cropY = Math.max(0, bbox.offsetY - insetPx);
const cropW = Math.min(full.w - cropX, bbox.width + insetPx * 2);
const cropH = Math.min(full.h - cropY, bbox.height + insetPx * 2);
// Make the crop square (max of W,H) so the resize keeps proportions and the subject still fills
const sq = Math.max(cropW, cropH);
const sqX = Math.max(0, cropX - Math.floor((sq - cropW) / 2));
const sqY = Math.max(0, cropY - Math.floor((sq - cropH) / 2));
const sqXClamped = Math.min(sqX, full.w - sq);
const sqYClamped = Math.min(sqY, full.h - sq);
console.log(`✂️ cropping to ${sq}x${sq}+${sqXClamped}+${sqYClamped} then resizing to ${args.targetSize}x${args.targetSize}`);
await $`magick ${args.input} -crop ${`${sq}x${sq}+${sqXClamped}+${sqYClamped}`} +repage -resize ${`${args.targetSize}x${args.targetSize}`} ${args.output}`.quiet();
// Re-measure on output
const outFull = await getDimensions(args.output);
const outBbox = await getBbox(args.output, bgColor, args.fuzz);
const after = marginPercents(outBbox, outFull);
console.log(`📏 after: bbox ${outBbox.width}x${outBbox.height}+${outBbox.offsetX}+${outBbox.offsetY} margins T:${after.top.toFixed(1)}% B:${after.bottom.toFixed(1)}% L:${after.left.toFixed(1)}% R:${after.right.toFixed(1)}%`);
const maxAfter = Math.max(after.top, after.bottom, after.left, after.right);
if (maxAfter > args.maxMargin) {
console.error(`❌ margins still exceed ${args.maxMargin}% after refill (max ${maxAfter.toFixed(1)}%)`);
process.exit(1);
}
console.log(`✅ refilled to full frame (max margin ${maxAfter.toFixed(1)}% ≤ ${args.maxMargin}%)`);
console.log(` ${args.output} (${(statSync(args.output).size / 1024).toFixed(0)} KB)`);
}
main().catch((e) => {
console.error(`❌ ${(e as Error).message}`);
process.exit(1);
});
#!/usr/bin/env bun
/**
* generate - UL Image Generation CLI
*
* Generate branded images using Flux 1.1 Pro, Nano Banana, Nano Banana Pro, or GPT-image-1.
* Follows llcli pattern for deterministic, composable CLI design.
*
* Usage:
* generate --model nano-banana-pro --prompt "..." --size 16:9 --output /tmp/image.png
*
* @see ~/.claude/skills/art/README.md
*/
import Replicate from "replicate";
import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";
import { writeFile, readFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
// ============================================================================
// Environment Loading
// ============================================================================
/**
* Load environment variables from ${PAI_DIR}/.env
* This ensures API keys are available regardless of how the CLI is invoked
*/
async function loadEnv(): Promise<void> {
const paiDir = process.env.PAI_DIR || resolve(process.env.HOME!, '.claude');
const envPath = resolve(paiDir, '.env');
try {
const envContent = await readFile(envPath, 'utf-8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Only set if not already defined (allow overrides from shell)
if (!process.env[key]) {
process.env[key] = value;
}
}
} catch (error) {
// Silently continue if .env doesn't exist - rely on shell env vars
}
// Canonical key aliases — {{PRINCIPAL_NAME}}'s .env uses _OPTIN suffix variants for some
// providers (data-usage opt-in keys). Tools that look up the bare name
// must transparently get the OPTIN value when no bare key is set.
// Add new aliases here when a provider has a suffix variant in the env.
const aliases: Record<string, string> = {
OPENAI_API_KEY: "OPENAI_API_KEY_OPTIN",
};
for (const [bare, suffixed] of Object.entries(aliases)) {
if (!process.env[bare] && process.env[suffixed]) {
process.env[bare] = process.env[suffixed];
}
}
}
// ============================================================================
// Types
// ============================================================================
type Model = "flux" | "nano-banana" | "nano-banana-pro" | "gpt-image-1" | "gpt-image-2" | "compare";
type ReplicateSize = "1:1" | "16:9" | "3:2" | "2:3" | "3:4" | "4:3" | "4:5" | "9:16" | "21:9";
type OpenAISize = "1024x1024" | "1536x1024" | "1024x1536";
type OpenAISize2 = "1024x1024" | "1536x1024" | "1024x1536" | "2048x2048" | "auto";
type GeminiSize = "1K" | "2K" | "4K";
type Quality = "low" | "medium" | "high" | "auto";
type Size = ReplicateSize | OpenAISize | OpenAISize2 | GeminiSize;
interface CLIArgs {
model: Model;
prompt: string;
size: Size;
output: string;
creativeVariations?: number;
aspectRatio?: ReplicateSize; // For Gemini models and compare mode (nano-banana side)
quality?: Quality; // For gpt-image-2 only
transparent?: boolean; // Enable transparent background
referenceImages?: string[]; // Reference image paths (Nano Banana Pro only) - up to 14 total
removeBg?: boolean; // Remove background after generation using local rembg
addBg?: string; // Add background color (hex) to transparent image
thumbnail?: boolean; // Generate additional thumbnail with #EAE9DF background for social previews
}
// ============================================================================
// Configuration
// ============================================================================
const DEFAULTS = {
model: "flux" as Model,
size: "16:9" as Size,
output: `${process.env.HOME}/Downloads/ul-image.png`,
};
const REPLICATE_SIZES: ReplicateSize[] = ["1:1", "16:9", "3:2", "2:3", "3:4", "4:3", "4:5", "5:4", "9:16", "21:9"];
const OPENAI_SIZES: OpenAISize[] = ["1024x1024", "1536x1024", "1024x1536"];
const OPENAI_V2_SIZES: OpenAISize2[] = ["1024x1024", "1536x1024", "1024x1536", "2048x2048", "auto"];
const GEMINI_SIZES: GeminiSize[] = ["1K", "2K", "4K"];
const QUALITY_VALUES: Quality[] = ["low", "medium", "high", "auto"];
// Aspect ratio mapping for Gemini (used with image size like 2K)
const GEMINI_ASPECT_RATIOS: ReplicateSize[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
// ============================================================================
// Error Handling
// ============================================================================
class CLIError extends Error {
constructor(message: string, public exitCode: number = 1) {
super(message);
this.name = "CLIError";
}
}
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(`❌ Error: ${error.message}`);
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(`❌ Unexpected error: ${error.message}`);
console.error(error.stack);
process.exit(1);
}
console.error(`❌ Unknown error:`, error);
process.exit(1);
}
// ============================================================================
// Image Format Detection
// ============================================================================
/**
* Detect actual image format from magic bytes.
* Prevents MIME type mismatch when API returns different format than requested.
*/
function detectImageFormat(data: Buffer | Uint8Array): { format: string; ext: string; mime: string } | null {
if (data.length < 12) return null;
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
return { format: "png", ext: ".png", mime: "image/png" };
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
return { format: "jpeg", ext: ".jpg", mime: "image/jpeg" };
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50)
return { format: "webp", ext: ".webp", mime: "image/webp" };
if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46)
return { format: "gif", ext: ".gif", mime: "image/gif" };
return null;
}
/**
* Save image data with correct file extension based on actual content format.
* Returns the final path (may differ from requested if format mismatch detected).
*/
async function saveImage(data: Buffer | Uint8Array | any, requestedPath: string): Promise<string> {
const buffer = data instanceof Buffer ? data : Buffer.from(data as any);
const detected = detectImageFormat(buffer);
if (detected) {
const requestedExt = extname(requestedPath).toLowerCase();
if (requestedExt && requestedExt !== detected.ext) {
const correctedPath = requestedPath.replace(/\.[^.]+$/, detected.ext);
console.warn(`⚠️ API returned ${detected.format.toUpperCase()} data (requested ${requestedExt.slice(1).toUpperCase()}). Saving as ${correctedPath}`);
await writeFile(correctedPath, buffer);
return correctedPath;
}
}
await writeFile(requestedPath, buffer);
return requestedPath;
}
/**
* Detect MIME type from image file content (magic bytes), falling back to extension.
*/
async function detectMimeType(filePath: string): Promise<string> {
try {
const data = await readFile(filePath);
const detected = detectImageFormat(data);
if (detected) return detected.mime;
} catch {
// Fall through to extension-based detection
}
const ext = extname(filePath).toLowerCase();
switch (ext) {
case ".png": return "image/png";
case ".jpg": case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
default: throw new CLIError(`Unsupported image format: ${ext}. Supported: .png, .jpg, .jpeg, .webp`);
}
}
// ============================================================================
// Help Text
// ============================================================================
// PAI directory for documentation paths
const PAI_DIR = process.env.PAI_DIR || `${process.env.HOME}/.claude`;
function showHelp(): void {
console.log(`
generate - UL Image Generation CLI
Generate branded images using Flux 1.1 Pro, Nano Banana, or GPT-image-1.
USAGE:
generate --model <model> --prompt "<prompt>" [OPTIONS]
REQUIRED:
--model <model> Model to use: flux, nano-banana, nano-banana-pro, gpt-image-1, gpt-image-2, compare
"compare" runs gpt-image-2 + nano-banana side-by-side for comparison
--prompt <text> Image generation prompt (quote if contains spaces)
OPTIONS:
--size <size> Image size/aspect ratio (default varies by model)
Replicate (flux, nano-banana): 1:1, 16:9, 3:2, 2:3, 3:4, 4:3, 4:5, 5:4, 9:16, 21:9
OpenAI gpt-image-1: 1024x1024, 1536x1024, 1024x1536
OpenAI gpt-image-2: 1024x1024, 1536x1024, 1024x1536, 2048x2048, auto
Gemini (nano-banana-pro): 1K, 2K, 4K (resolution); aspect ratio inferred or 16:9
compare mode: pass gpt-image-2 size here; nano side uses --aspect-ratio
--aspect-ratio <ratio> Aspect ratio for Gemini nano-banana-pro AND compare-mode nano-banana side
Options: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9 (default 16:9)
--quality <level> Quality for gpt-image-2 only: low, medium, high, auto (default: high)
--output <path> Output file path (default: /tmp/ul-image.png)
--reference-image <path> Reference image for style/character consistency (Nano Banana Pro only)
Can specify MULTIPLE times for improved consistency
Accepts: PNG, JPEG, WebP images
API Limits: Up to 5 human refs, 6 object refs, 14 total max
--transparent Enable transparent background (adds transparency instructions to prompt)
Note: Not all models support transparency natively; may require post-processing
--remove-bg Remove background after generation using local rembg
Creates true transparency by removing the generated background
--add-bg <hex> Add background color to a transparent image (e.g., "#EAE9DF")
Useful for creating thumbnails/social previews from transparent images
--thumbnail Generate BOTH transparent AND thumbnail versions for blog headers
Creates: output.png (transparent) + output-thumb.png (#EAE9DF background)
Automatically enables --remove-bg
--creative-variations <n> Generate N variations (appends -v1, -v2, etc. to output filename)
Use with the be-creative skill for true prompt diversity
CLI mode: generates N images with same prompt (tests model variability)
--help, -h Show this help message
EXAMPLES:
# Generate blog header with Nano Banana Pro (16:9, 2K quality)
generate --model nano-banana-pro --prompt "Abstract UL illustration..." --size 2K --aspect-ratio 16:9
# Generate high-res 4K image with Nano Banana Pro
generate --model nano-banana-pro --prompt "Editorial cover..." --size 4K --aspect-ratio 3:2
# Generate blog header with original Nano Banana (16:9)
generate --model nano-banana --prompt "Abstract UL illustration..." --size 16:9
# Generate square image with Flux
generate --model flux --prompt "Minimal geometric art..." --size 1:1 --output /tmp/header.png
# Generate portrait with GPT-image-1
generate --model gpt-image-1 --prompt "Editorial cover..." --size 1024x1536
# Generate with NEW gpt-image-2 (ChatGPT Images 2.0) at 2K with high quality
generate --model gpt-image-2 --prompt "Editorial cover..." --size 2048x2048 --quality high
# Compare mode: 3 images from gpt-image-2 + 3 from nano-banana side-by-side
generate --model compare --prompt "Abstract illustration..." \\
--creative-variations 3 --size 1024x1024 --aspect-ratio 1:1 \\
--output /tmp/shootout.png
# Outputs: /tmp/shootout-gpt2-{1,2,3}.png + /tmp/shootout-nano-{1,2,3}.png
# Generate 3 creative variations (for testing model variability)
generate --model gpt-image-1 --prompt "..." --creative-variations 3 --output /tmp/essay.png
# Outputs: /tmp/essay-v1.png, /tmp/essay-v2.png, /tmp/essay-v3.png
# Single reference image for style guidance (Nano Banana Pro only)
generate --model nano-banana-pro --prompt "Tokyo Night themed illustration..." \\
--reference-image /tmp/style-reference.png --size 2K --aspect-ratio 16:9
# MULTIPLE reference images for character consistency (Nano Banana Pro only)
generate --model nano-banana-pro --prompt "Person from references at a party..." \\
--reference-image face1.jpg --reference-image face2.jpg --reference-image face3.jpg \\
--size 2K --aspect-ratio 16:9
NOTE: For true creative diversity with different prompts, use the creative workflow which
integrates the be-creative skill. CLI creative mode generates multiple images with the SAME prompt.
MULTI-REFERENCE LIMITS (Gemini API):
- Up to 5 human reference images for character consistency
- Up to 6 object reference images
- Maximum 14 total reference images per request
ENVIRONMENT VARIABLES:
REPLICATE_API_TOKEN Required for flux and nano-banana models
OPENAI_API_KEY Required for gpt-image-1 model
GOOGLE_API_KEY Required for nano-banana-pro model
REMBG_BIN Optional override for rembg binary path (default: ~/.local/bin/rembg)
ERROR CODES:
0 Success
1 General error (invalid arguments, API error, file write error)
MORE INFO:
Documentation: ${PAI_DIR}/skills/Art/README.md
Source: ${PAI_DIR}/skills/Art/Tools/Generate.ts
`);
process.exit(0);
}
// ============================================================================
// Argument Parsing
// ============================================================================
function parseArgs(argv: string[]): CLIArgs {
const args = argv.slice(2);
// Check for help flag
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
showHelp();
}
const parsed: Partial<CLIArgs> = {
model: DEFAULTS.model,
output: DEFAULTS.output,
};
// Collect reference images into array
const referenceImages: string[] = [];
// Parse arguments
for (let i = 0; i < args.length; i++) {
const flag = args[i];
if (!flag.startsWith("--")) {
throw new CLIError(`Invalid flag: ${flag}. Flags must start with --`);
}
const key = flag.slice(2);
// Handle boolean flags (no value)
if (key === "transparent") {
parsed.transparent = true;
continue;
}
if (key === "remove-bg") {
parsed.removeBg = true;
continue;
}
if (key === "thumbnail") {
parsed.thumbnail = true;
parsed.removeBg = true; // Thumbnail mode requires remove-bg
continue;
}
// Handle flags with values
const value = args[i + 1];
if (!value || value.startsWith("--")) {
throw new CLIError(`Missing value for flag: ${flag}`);
}
switch (key) {
case "model":
if (
value !== "flux" &&
value !== "nano-banana" &&
value !== "nano-banana-pro" &&
value !== "gpt-image-2" &&
value !== "compare"
) {
if (value === "gpt-image-1") {
throw new CLIError(
`gpt-image-1 is DEPRECATED per OpenAI docs. Use --model gpt-image-2 instead (current OpenAI image model, released Apr 21 2026, #1 on Artificial Analysis Image Arena).`
);
}
throw new CLIError(
`Invalid model: ${value}. Must be: flux, nano-banana, nano-banana-pro, gpt-image-2, or compare`
);
}
parsed.model = value;
i++; // Skip next arg (value)
break;
case "quality":
if (!QUALITY_VALUES.includes(value as Quality)) {
throw new CLIError(`Invalid quality: ${value}. Must be: ${QUALITY_VALUES.join(", ")}`);
}
parsed.quality = value as Quality;
i++;
break;
case "prompt":
parsed.prompt = value;
i++; // Skip next arg (value)
break;
case "size":
parsed.size = value as Size;
i++; // Skip next arg (value)
break;
case "aspect-ratio":
parsed.aspectRatio = value as ReplicateSize;
i++; // Skip next arg (value)
break;
case "output":
parsed.output = value;
i++; // Skip next arg (value)
break;
case "reference-image":
// Collect multiple reference images into array
referenceImages.push(value);
i++; // Skip next arg (value)
break;
case "creative-variations":
const variations = parseInt(value, 10);
if (isNaN(variations) || variations < 1 || variations > 10) {
throw new CLIError(`Invalid creative-variations: ${value}. Must be 1-10`);
}
parsed.creativeVariations = variations;
i++; // Skip next arg (value)
break;
case "add-bg":
// Validate hex color format
if (!/^#[0-9A-Fa-f]{6}$/.test(value)) {
throw new CLIError(`Invalid hex color: ${value}. Must be in format #RRGGBB (e.g., #EAE9DF)`);
}
parsed.addBg = value;
i++; // Skip next arg (value)
break;
default:
throw new CLIError(`Unknown flag: ${flag}`);
}
}
// Assign collected reference images if any
if (referenceImages.length > 0) {
parsed.referenceImages = referenceImages;
}
// Validate required arguments
if (!parsed.prompt) {
throw new CLIError("Missing required argument: --prompt");
}
if (!parsed.model) {
throw new CLIError("Missing required argument: --model");
}
// Validate reference-image is only used with nano-banana-pro
if (parsed.referenceImages && parsed.referenceImages.length > 0 && parsed.model !== "nano-banana-pro") {
throw new CLIError("--reference-image is only supported with --model nano-banana-pro");
}
// Validate reference image count (API limits: 5 human, 6 object, 14 total max)
if (parsed.referenceImages && parsed.referenceImages.length > 14) {
throw new CLIError(`Too many reference images: ${parsed.referenceImages.length}. Maximum is 14 total (5 human, 6 object)`);
}
// Quality is only valid for gpt-image-2
if (parsed.quality && parsed.model !== "gpt-image-2" && parsed.model !== "compare") {
throw new CLIError(`--quality is only supported with --model gpt-image-2 (or compare)`);
}
// Set model-appropriate default size if not explicitly provided
if (!parsed.size) {
switch (parsed.model) {
case "gpt-image-2":
parsed.size = "1024x1024";
break;
case "nano-banana-pro":
parsed.size = "2K";
break;
case "compare":
// compare mode: use aspect-ratio for nano side, gpt-image-2 size default
parsed.size = "1024x1024";
break;
default: // flux, nano-banana
parsed.size = "16:9";
break;
}
}
// Validate size based on model
if (parsed.model === "gpt-image-2") {
if (!OPENAI_V2_SIZES.includes(parsed.size as OpenAISize2)) {
throw new CLIError(`Invalid size for gpt-image-2: ${parsed.size}. Must be: ${OPENAI_V2_SIZES.join(", ")}`);
}
} else if (parsed.model === "compare") {
if (!OPENAI_V2_SIZES.includes(parsed.size as OpenAISize2)) {
throw new CLIError(`Invalid size for compare (gpt-image-2 side): ${parsed.size}. Must be: ${OPENAI_V2_SIZES.join(", ")}`);
}
if (parsed.aspectRatio && !REPLICATE_SIZES.includes(parsed.aspectRatio as ReplicateSize)) {
throw new CLIError(`Invalid aspect-ratio for compare (nano-banana side): ${parsed.aspectRatio}. Must be: ${REPLICATE_SIZES.join(", ")}`);
}
if (!parsed.aspectRatio) parsed.aspectRatio = "1:1";
} else if (parsed.model === "nano-banana-pro") {
if (!GEMINI_SIZES.includes(parsed.size as GeminiSize)) {
throw new CLIError(`Invalid size for nano-banana-pro: ${parsed.size}. Must be: ${GEMINI_SIZES.join(", ")}`);
}
// Validate aspect ratio if provided
if (parsed.aspectRatio && !GEMINI_ASPECT_RATIOS.includes(parsed.aspectRatio)) {
throw new CLIError(`Invalid aspect-ratio for nano-banana-pro: ${parsed.aspectRatio}. Must be: ${GEMINI_ASPECT_RATIOS.join(", ")}`);
}
// Default to 16:9 if not specified
if (!parsed.aspectRatio) {
parsed.aspectRatio = "16:9";
}
} else {
if (!REPLICATE_SIZES.includes(parsed.size as ReplicateSize)) {
throw new CLIError(`Invalid size for ${parsed.model}: ${parsed.size}. Must be: ${REPLICATE_SIZES.join(", ")}`);
}
}
return parsed as CLIArgs;
}
// ============================================================================
// Prompt Enhancement
// ============================================================================
function enhancePromptForTransparency(prompt: string): string {
const transparencyPrefix = "CRITICAL: Transparent background (PNG with alpha channel) - NO background color, pure transparency. Object floating in transparent space. ";
return transparencyPrefix + prompt;
}
// ============================================================================
// Background Removal
// ============================================================================
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
// ============================================================================
// Background Operations
// ============================================================================
/**
* Add a solid background color to a transparent PNG image
* Uses ImageMagick to composite the transparent image onto a colored background
*/
async function addBackgroundColor(inputPath: string, outputPath: string, hexColor: string): Promise<void> {
console.log(`🎨 Adding background color ${hexColor} to image...`);
// Use ImageMagick to composite the transparent image onto a colored background
// -background sets the fill color, -flatten composites onto that background
const command = `magick "${inputPath}" -background "${hexColor}" -flatten "${outputPath}"`;
try {
await execAsync(command);
console.log(`✅ Thumbnail saved to ${outputPath}`);
} catch (error) {
throw new CLIError(`Failed to add background color: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function removeBackground(imagePath: string): Promise<string> {
const home = process.env.HOME;
if (!home) throw new CLIError("HOME not set; cannot resolve rembg binary");
const rembgBin = process.env.REMBG_BIN || resolve(home, ".local/bin/rembg");
const { existsSync } = await import("node:fs");
if (!existsSync(rembgBin)) {
throw new CLIError(
`rembg not found at ${rembgBin}. Install: pipx install rembg (or set REMBG_BIN env var to override path).`
);
}
console.log("🔲 Removing background with local rembg...");
// rembg always emits PNG. Force the output path to .png so we don't end up
// with PNG bytes inside a .jpg extension.
const currentExt = extname(imagePath).toLowerCase();
const finalPath = currentExt === ".png" ? imagePath : imagePath.replace(/\.[^.]+$/, ".png");
// rembg truncates output before reading input, so input == output corrupts
// the file. Always write to a temp path, then rename.
const tempPath = finalPath.replace(/\.png$/, `.rembg-tmp.png`);
const { spawn } = await import("node:child_process");
await new Promise<void>((resolveFn, rejectFn) => {
const proc = spawn(rembgBin, ["i", imagePath, tempPath], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
proc.on("error", (err) => rejectFn(new CLIError(`Failed to launch rembg: ${err.message}`)));
proc.on("close", (code) => {
if (code === 0) resolveFn();
else rejectFn(new CLIError(`rembg exited ${code}: ${stderr.trim()}`));
});
});
const { unlink, rename } = await import("node:fs/promises");
// Drop the original (whether .jpg or the .png we're about to overwrite)
try { await unlink(imagePath); } catch {}
await rename(tempPath, finalPath);
if (finalPath !== imagePath) {
console.log(` ↪ renamed ${currentExt} → .png (transparency requires PNG): ${finalPath}`);
}
// Validate output is actually PNG with alpha
const result = await readFile(finalPath);
const detected = detectImageFormat(result);
if (!detected || detected.format !== "png") {
throw new CLIError(
`rembg produced non-PNG output (got ${detected?.format ?? "unknown"}). Transparency requires PNG.`
);
}
console.log("✅ Background removed successfully");
return finalPath;
}
// ============================================================================
// Image Generation
// ============================================================================
async function generateWithFlux(prompt: string, size: ReplicateSize, output: string): Promise<string> {
const token = process.env.REPLICATE_API_TOKEN;
if (!token) {
throw new CLIError("Missing environment variable: REPLICATE_API_TOKEN");
}
const replicate = new Replicate({ auth: token });
console.log("🎨 Generating with Flux 1.1 Pro...");
const result = await replicate.run("black-forest-labs/flux-1.1-pro", {
input: {
prompt,
aspect_ratio: size,
output_format: "png",
output_quality: 95,
prompt_upsampling: false,
},
});
// Replicate SDK may return a FileOutput object with a url() method or toString()
let imageData: Buffer;
if (result && typeof (result as any).blob === "function") {
// FileOutput (Replicate SDK v1+) — has blob() method
const blob = await (result as any).blob();
imageData = Buffer.from(await blob.arrayBuffer());
} else if (result && typeof (result as any).url === "function") {
const url = (result as any).url().href ?? (result as any).url();
const resp = await fetch(url);
imageData = Buffer.from(await resp.arrayBuffer());
} else if (result && typeof (result as any).arrayBuffer === "function") {
imageData = Buffer.from(await (result as any).arrayBuffer());
} else if (typeof result === "string" && result.startsWith("http")) {
const resp = await fetch(result);
imageData = Buffer.from(await resp.arrayBuffer());
} else {
imageData = result as Buffer;
}
const finalPath = await saveImage(imageData, output);
console.log(`✅ Image saved to ${finalPath}`);
return finalPath;
}
async function generateWithNanoBanana(prompt: string, size: ReplicateSize, output: string): Promise<string> {
const token = process.env.REPLICATE_API_TOKEN;
if (!token) {
throw new CLIError("Missing environment variable: REPLICATE_API_TOKEN");
}
const replicate = new Replicate({ auth: token });
console.log("🍌 Generating with Nano Banana...");
const result = await replicate.run("google/nano-banana", {
input: {
prompt,
aspect_ratio: size,
output_format: "png",
},
});
// Handle FileOutput from Replicate SDK v1+
let imageData: Buffer;
if (result && typeof (result as any).blob === "function") {
const blob = await (result as any).blob();
imageData = Buffer.from(await blob.arrayBuffer());
} else if (result && typeof (result as any).url === "function") {
const url = (result as any).url().href ?? (result as any).url();
const resp = await fetch(url);
imageData = Buffer.from(await resp.arrayBuffer());
} else if (typeof result === "string" && (result as string).startsWith("http")) {
const resp = await fetch(result as string);
imageData = Buffer.from(await resp.arrayBuffer());
} else {
imageData = result as Buffer;
}
const finalPath = await saveImage(imageData, output);
console.log(`✅ Image saved to ${finalPath}`);
return finalPath;
}
async function generateWithGPTImage(prompt: string, size: OpenAISize, output: string): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new CLIError("Missing environment variable: OPENAI_API_KEY");
}
const openai = new OpenAI({ apiKey });
console.log("🤖 Generating with GPT-image-1...");
const response = await openai.images.generate({
model: "gpt-image-1",
prompt,
size,
n: 1,
});
const imageData = response.data[0].b64_json;
if (!imageData) {
throw new CLIError("No image data returned from OpenAI API");
}
const imageBuffer = Buffer.from(imageData, "base64");
const finalPath = await saveImage(imageBuffer, output);
console.log(`✅ Image saved to ${finalPath}`);
return finalPath;
}
async function generateWithGPTImage2(
prompt: string,
size: OpenAISize2,
quality: Quality,
n: number,
outputBase: string
): Promise<string[]> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new CLIError("Missing environment variable: OPENAI_API_KEY");
}
const openai = new OpenAI({ apiKey });
console.log(`🧠 Generating with gpt-image-2 (ChatGPT Images 2.0) — size=${size} quality=${quality} n=${n}...`);
const response = await openai.images.generate({
model: "gpt-image-2",
prompt,
size,
quality,
n,
} as any);
const data = (response as any).data;
if (!Array.isArray(data) || data.length === 0) {
throw new CLIError("No image data returned from OpenAI gpt-image-2 API");
}
const paths: string[] = [];
for (let i = 0; i < data.length; i++) {
const item = data[i];
let buffer: Buffer;
if (item.b64_json) {
buffer = Buffer.from(item.b64_json, "base64");
} else if (item.url) {
const resp = await fetch(item.url);
buffer = Buffer.from(await resp.arrayBuffer());
} else {
throw new CLIError(`gpt-image-2 returned image ${i + 1} with neither b64_json nor url`);
}
const target = data.length === 1 ? outputBase : outputBase.replace(/\.[^.]+$/, `-${i + 1}.png`);
const finalPath = await saveImage(buffer, target);
console.log(`✅ gpt-image-2 image saved to ${finalPath}`);
paths.push(finalPath);
}
return paths;
}
async function generateWithNanoBananaPro(
prompt: string,
size: GeminiSize,
aspectRatio: ReplicateSize,
output: string,
referenceImages?: string[]
): Promise<string> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
throw new CLIError("Missing environment variable: GOOGLE_API_KEY");
}
const ai = new GoogleGenAI({ apiKey });
if (referenceImages && referenceImages.length > 0) {
console.log(`🍌✨ Generating with Nano Banana Pro (Gemini 3 Pro) at ${size} ${aspectRatio} with ${referenceImages.length} reference image(s)...`);
} else {
console.log(`🍌✨ Generating with Nano Banana Pro (Gemini 3 Pro) at ${size} ${aspectRatio}...`);
}
// Prepare content parts
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [];
// Add all reference images if provided
if (referenceImages && referenceImages.length > 0) {
for (const referenceImage of referenceImages) {
// Read image file
const imageBuffer = await readFile(referenceImage);
const imageBase64 = imageBuffer.toString("base64");
// Detect MIME type from actual file content (magic bytes), not just extension
const mimeType = await detectMimeType(referenceImage);
parts.push({
inlineData: {
mimeType,
data: imageBase64,
},
});
}
}
// Add text prompt
parts.push({ text: prompt });
const response = await ai.models.generateContent({
model: "gemini-3-pro-image-preview",
contents: [{ parts }],
config: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: aspectRatio,
imageSize: size,
},
},
});
// Extract image data from response
let imageData: string | undefined;
if (response.candidates && response.candidates.length > 0) {
const parts = response.candidates[0].content.parts;
for (const part of parts) {
// Check if this part contains inline image data
if (part.inlineData && part.inlineData.data) {
imageData = part.inlineData.data;
break;
}
}
}
if (!imageData) {
throw new CLIError("No image data returned from Gemini API");
}
const imageBuffer = Buffer.from(imageData, "base64");
const finalPath = await saveImage(imageBuffer, output);
console.log(`✅ Image saved to ${finalPath}`);
return finalPath;
}
// ============================================================================
// Main
// ============================================================================
async function main(): Promise<void> {
try {
// Load API keys from ${PAI_DIR}/.env
await loadEnv();
const args = parseArgs(process.argv);
// Enhance prompt for transparency if requested
const finalPrompt = args.transparent
? enhancePromptForTransparency(args.prompt)
: args.prompt;
if (args.transparent) {
console.log("🔲 Transparent background mode enabled");
console.log("💡 Note: Not all models support transparency natively; may require post-processing\n");
}
const n = args.creativeVariations && args.creativeVariations > 1 ? args.creativeVariations : 1;
const quality: Quality = args.quality ?? "high";
// Compare mode: generate N images with gpt-image-2 + N with nano-banana, side-by-side
if (args.model === "compare") {
console.log(`⚖️ Compare Mode: ${n} image(s) from gpt-image-2 + ${n} from nano-banana (total ${n * 2})`);
const basePath = args.output.replace(/\.[^.]+$/, "");
const gptBase = `${basePath}-gpt2.png`;
const nanoBase = `${basePath}-nano.png`;
const gptPromise = generateWithGPTImage2(
finalPrompt,
args.size as OpenAISize2,
quality,
n,
gptBase
).catch((err) => {
console.error(`❌ gpt-image-2 side failed: ${err instanceof Error ? err.message : err}`);
return [] as string[];
});
const nanoPromises: Promise<string>[] = [];
for (let i = 1; i <= n; i++) {
const nanoOutput = n === 1 ? nanoBase : `${basePath}-nano-${i}.png`;
nanoPromises.push(
generateWithNanoBanana(finalPrompt, args.aspectRatio!, nanoOutput).catch((err) => {
console.error(`❌ nano-banana variation ${i} failed: ${err instanceof Error ? err.message : err}`);
return "";
})
);
}
const [gptPaths, nanoPathsRaw] = await Promise.all([gptPromise, Promise.all(nanoPromises)]);
const nanoPaths = nanoPathsRaw.filter(Boolean);
console.log(`\n✅ Compare complete — gpt-image-2: ${gptPaths.length}/${n}, nano-banana: ${nanoPaths.length}/${n}`);
console.log(` gpt-image-2: ${gptPaths.join(", ") || "(none)"}`);
console.log(` nano-banana: ${nanoPaths.join(", ") || "(none)"}`);
return;
}
// Single-model multi-image (creative-variations) path
if (n > 1) {
console.log(`🎨 Creative Mode: Generating ${n} variations with ${args.model}...`);
console.log(`💡 Note: CLI mode uses same prompt for all variations (tests model variability)`);
console.log(` For true creative diversity, use the creative workflow with be-creative skill\n`);
const basePath = args.output.replace(/\.[^.]+$/, "");
// gpt-image-2 supports batch n natively — single API call
if (args.model === "gpt-image-2") {
const paths = await generateWithGPTImage2(
finalPrompt,
args.size as OpenAISize2,
quality,
n,
`${basePath}.png`
);
console.log(`\n✅ Generated ${paths.length} variation(s)`);
console.log(` Files: ${paths.join(", ")}`);
return;
}
// Other models: fan out in parallel
const promises: Promise<string>[] = [];
for (let i = 1; i <= n; i++) {
const varOutput = `${basePath}-v${i}.png`;
console.log(`Variation ${i}/${n}: ${varOutput}`);
if (args.model === "flux") {
promises.push(generateWithFlux(finalPrompt, args.size as ReplicateSize, varOutput));
} else if (args.model === "nano-banana") {
promises.push(generateWithNanoBanana(finalPrompt, args.size as ReplicateSize, varOutput));
} else if (args.model === "nano-banana-pro") {
promises.push(
generateWithNanoBananaPro(
finalPrompt,
args.size as GeminiSize,
args.aspectRatio!,
varOutput,
args.referenceImages
)
);
} else if (args.model === "gpt-image-1") {
promises.push(generateWithGPTImage(finalPrompt, args.size as OpenAISize, varOutput));
}
}
const actualPaths = await Promise.all(promises);
console.log(`\n✅ Generated ${n} variations`);
console.log(` Files: ${actualPaths.join(", ")}`);
return;
}
// Standard single image generation — track actual output path (may differ if format corrected)
let actualOutput: string = args.output;
if (args.model === "flux") {
actualOutput = await generateWithFlux(finalPrompt, args.size as ReplicateSize, args.output);
} else if (args.model === "nano-banana") {
actualOutput = await generateWithNanoBanana(finalPrompt, args.size as ReplicateSize, args.output);
} else if (args.model === "nano-banana-pro") {
actualOutput = await generateWithNanoBananaPro(
finalPrompt,
args.size as GeminiSize,
args.aspectRatio!,
args.output,
args.referenceImages
);
} else if (args.model === "gpt-image-1") {
actualOutput = await generateWithGPTImage(finalPrompt, args.size as OpenAISize, args.output);
} else if (args.model === "gpt-image-2") {
const paths = await generateWithGPTImage2(
finalPrompt,
args.size as OpenAISize2,
quality,
1,
args.output
);
actualOutput = paths[0];
}
// Remove background if requested (use actual output path)
// May return a renamed path (e.g., .jpg → .png) since rembg returns PNG.
if (args.removeBg) {
actualOutput = await removeBackground(actualOutput);
}
// Add background color if requested (standalone mode)
if (args.addBg && !args.thumbnail) {
// For standalone --add-bg, modify the image in place
const tempPath = actualOutput.replace(/\.[^.]+$/, "-temp.png");
await addBackgroundColor(actualOutput, tempPath, args.addBg);
// Replace original with the one with background
const { rename } = await import("node:fs/promises");
await rename(tempPath, actualOutput);
}
// Generate thumbnail with background color if requested (blog header mode)
if (args.thumbnail) {
const thumbPath = actualOutput.replace(/\.[^.]+$/, "-thumb.png");
const THUMBNAIL_BG_COLOR = "#EAE9DF"; // UL brand background color for social previews
await addBackgroundColor(actualOutput, thumbPath, THUMBNAIL_BG_COLOR);
console.log(`\n📸 Blog header mode: Created both versions`);
console.log(` Transparent: ${actualOutput}`);
console.log(` Thumbnail: ${thumbPath}`);
}
} catch (error) {
handleError(error);
}
}
main();
{
"name": "tools",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@google/genai": "^1.40.0",
"openai": "^6.18.0",
"replicate": "^1.4.0"
}
}
tools
To install dependencies:
bun installTo run:
bun run This project was created using bun init in bun v1.3.6. Bun is a fast all-in-one JavaScript runtime.
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}
PAI Pack Icon Workflow
Generate 256x256 transparent PNG icons for PAI packs.
Voice Notification
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the CreatePAIPackIcon workflow in the Art skill to generate pack icons"}' \
> /dev/null 2>&1 &Running CreatePAIPackIcon in Art...
---
Purpose
Create consistent, professional icons for PAI packs following the established visual identity.
Use for: New pack icons, icon refreshes, icon regeneration.
---
Visual Specifications
Required Specs
| Spec | Value |
|---|---|
| Dimensions | 256x256 pixels |
| Format | PNG with transparency |
| Background | ACTUAL transparent (not checkerboard) |
| Primary Color | Electric blue #4a90d9 |
| Accent Color | Purple #8b5cf6 (10-15% max) |
| Style | Simple, flat, readable at 64x64 |
Color Palette
Background: Transparent (actual transparency, not pattern)
Primary: Electric Blue #4a90d9 (dominant color)
Accent: Purple #8b5cf6 (sparingly, 10-15% of design)
Optional Dark: Dark #0a0a0f (for contrast elements if needed)Design Rules
1. Simple geometry - Icon must be readable at 64x64 pixels 2. Conceptual - Represent the pack's core function visually 3. Consistent style - Match existing PAI pack icons 4. No text - Icons should work without labels 5. Centered - Icon should be centered in the 256x256 canvas
---
Workflow Steps
Step 1: Understand Pack Purpose
Before generating, understand:
- What does this pack do?
- What visual metaphor represents it?
- How should it relate to other pack icons?
Good icon concepts:
pai-hook-system→ Hook shape, event triggerpai-core-install→ Download/install arrowpai-skill-system→ Brain/routing/capabilitypai-agent-system→ Robot/assistant figurepai-voice-system→ Sound wave/speaker
Step 2: Construct Prompt
Build a prompt that specifies: 1. The visual concept 2. The style (simple flat icon) 3. The color palette 4. The size requirements
Prompt template:
[VISUAL CONCEPT representing {pack function}], simple flat icon design, 256x256 pixels.
COLOR PALETTE: Primary electric blue (#4a90d9), Accent purple (#8b5cf6) sparingly.
STYLE: Modern flat icon, simple enough to read at 64x64, no text, centered.
BACKGROUND: Dark (#0a0a0f) - will be removed for transparency.Step 3: Generate Icon
Command:
bun run ~/.claude/skills/Art/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "[YOUR_PROMPT]" \
--size 1K \
--aspect-ratio 1:1 \
--remove-bg \
--output ${PROJECTS_DIR}/PAI/Packs/icons/[PACK_NAME].pngFlags explained:
--model nano-banana-pro- Best quality for icons--size 1K- Small file, fast generation--aspect-ratio 1:1- Square for icons--remove-bg- Creates actual transparency
Step 4: Verify Output
Check the generated icon:
# Verify file exists and size
ls -la ${PROJECTS_DIR}/PAI/Packs/icons/[PACK_NAME].png
# Check dimensions (requires imagemagick)
file ${PROJECTS_DIR}/PAI/Packs/icons/[PACK_NAME].pngVerification checklist:
- [ ] File exists at correct location
- [ ] PNG format
- [ ] Approximately 256x256 dimensions
- [ ] Has transparency (no solid background)
- [ ] Uses blue/purple palette
- [ ] Readable at small size
---
Examples
Example 1: Hook System Pack
bun run ~/.claude/skills/Art/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "A stylized hook or fishing hook shape representing event hooks in software, simple flat icon design, 256x256 pixels. COLOR PALETTE: Primary electric blue (#4a90d9), Accent purple (#8b5cf6) sparingly. STYLE: Modern flat icon, simple enough to read at 64x64, no text, centered. BACKGROUND: Dark (#0a0a0f)." \
--size 1K \
--aspect-ratio 1:1 \
--remove-bg \
--output ${PROJECTS_DIR}/PAI/Packs/icons/pai-hook-system.pngExample 2: Core Install Pack
bun run ~/.claude/skills/Art/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "A download arrow pointing into a foundation/base structure representing core installation, simple flat icon design, 256x256 pixels. COLOR PALETTE: Primary electric blue (#4a90d9), Accent purple (#8b5cf6) sparingly. STYLE: Modern flat icon, simple enough to read at 64x64, no text, centered. BACKGROUND: Dark (#0a0a0f)." \
--size 1K \
--aspect-ratio 1:1 \
--remove-bg \
--output ${PROJECTS_DIR}/PAI/Packs/icons/pai-core-install.pngExample 3: Memory System Pack
bun run ~/.claude/skills/Art/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "A brain with memory/data flowing in and out representing an AI memory system, simple flat icon design, 256x256 pixels. COLOR PALETTE: Primary electric blue (#4a90d9), Accent purple (#8b5cf6) sparingly. STYLE: Modern flat icon, simple enough to read at 64x64, no text, centered. BACKGROUND: Dark (#0a0a0f)." \
--size 1K \
--aspect-ratio 1:1 \
--remove-bg \
--output ${PROJECTS_DIR}/PAI/Packs/icons/pai-memory-system.png---
Output Location
All PAI pack icons go to:
${PROJECTS_DIR}/PAI/Packs/icons/[PACK_NAME].pngNaming convention: Match the pack directory name exactly.
- Pack:
Packs/pai-hook-system/ - Icon:
Packs/icons/pai-hook-system.png
---
Regeneration
If an icon needs to be regenerated:
1. Delete the old icon 2. Run the generate command with updated prompt 3. Verify the new icon 4. Update README if icon changed significantly
---
Validation Checklist
Before marking icon complete:
- [ ] Exists at
${PROJECTS_DIR}/PAI/Packs/icons/[PACK_NAME].png - [ ] Format is PNG with transparency
- [ ] Size approximately 256x256
- [ ] Colors use blue primary, purple accent
- [ ] Readable at 64x64 size
- [ ] Conceptual - represents pack function
- [ ] Consistent - matches other PAI icons in style
---
Related Workflows
~/.claude/skills/_PAI/Workflows/CreateRelease.md- Release workflow (may include icon generation)
Note: Previously referenced CreatePack.md, ValidatePack.md, and PAIIntegrityCheck.md have been removed.
---
Last Updated: 2026-01-10