
Art
- 1 installs
- 1 repo stars
- Updated July 30, 2026
- aojdevstudio/agentic-utilities
Art is a skill that creates visual assets such as editorial illustrations, diagrams, infographics, thumbnails, and icons, routing each request to a matching workflow.
About
A visual-asset skill for creating a wide range of imagery: editorial illustrations, blog headers, technical and Mermaid diagrams, infographics, comparisons, timelines, thumbnails, icons, and comics. A developer uses it to route a request to the matching workflow, do concept analysis, and generate images via local backends, outputting to Downloads for review. When image generation is unavailable it still delivers a strong concept, a production-ready prompt, and a Mermaid/SVG/ASCII fallback.
- Creates editorial illustrations, blog headers, diagrams, infographics, thumbnails, icons, and more
- Routes each request to a matching workflow file and generates via local backends (nano-banana-pro, flux, gpt-image-1)
- Falls back to Mermaid/SVG/ASCII and a production-ready prompt when direct image generation is unavailable
Art by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
art capabilities & compatibility
Uses image backends that require credentials (Google, Replicate, OpenAI)
- Capabilities
- image generation · diagram generation · art direction
- Use cases
- image generation · ui design
- Pricing
- Bring your own API key
What art says it does
Creates visual assets with high creative range: editorial illustrations, blog headers, technical diagrams, Mermaid diagrams, infographics, thumbnails, wallpapers, icons, comics
a Mermaid/SVG/ASCII fallback if image generation is not the right tool.
npx skills add https://github.com/aojdevstudio/agentic-utilities --skill artAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 30, 2026 |
| Repository | aojdevstudio/agentic-utilities ↗ |
What it does
Create editorial illustrations, diagrams, infographics, thumbnails, and other visual assets with workflow routing.
Who is it for?
Editorial illustrations, blog headers, technical and Mermaid diagrams, infographics, thumbnails, icons, and comics.
When should I use this skill?
When the user wants an image, diagram, visual concept, prompt, layout, or art direction.
What you get
A publication-quality visual, or a strong concept plus production-ready prompt and fallback when generation is unavailable.
- A generated visual in ~/Downloads/, or a concept plus production-ready prompt and fallback
By the numbers
- 20+ visual formats supported
- 4 image backends (nano-banana-pro, flux, nano-banana, gpt-image-1)
Files
Art
High-creativity visual content skill for Pi.
This is a Pi-native adaptation of your Claude Art workflow library. The original workflow docs, tools, and examples are included in this skill directory so Pi can reuse the same creative system.
What This Skill Is For
Use this skill when the task involves:
- editorial illustrations
- blog headers and hero images
- technical diagrams
- Mermaid-style diagrams
- comparisons, frameworks, taxonomies, timelines, maps, stats
- thumbnails, icons, wallpapers, comics
- prompt engineering for image generation
- visual direction, composition, and aesthetic decisions
Pi Adaptation Rules
The imported workflow files contain some legacy Claude/PAI-only instructions. Translate them like this:
- Ignore all voice notification steps,
curl ${PAI_NOTIFY_URL:-}, and similar harness-specific commands. - Ignore slash commands like
/cse. When a workflow says to run story explanation or CSE-24, do the equivalent analysis yourself in-chat: produce a deep 24-point narrative or structural breakdown before drafting the visual. - Ignore references to
AskUserQuestion,TaskGet,TaskUpdate,${PROJECTS_DIR}, and similar unavailable tooling. - Treat
${PAI_DIR}references as this skill directory:~/.pi/agent/skills/art/. - For generation, prefer the local tool at
Tools/Generate.ts. - Always output generated images to
~/Downloads/first for review. - If direct image generation is unavailable, still complete the creative work by producing:
1. a strong concept, 2. a polished production-ready prompt, 3. model/size/aspect-ratio recommendations, 4. file naming and placement guidance, 5. Mermaid/SVG/ASCII fallback where appropriate.
Available Local Assets
- Workflows:
Workflows/*.md - Image generation CLI:
Tools/Generate.ts - Prompt tooling:
Tools/GeneratePrompt.ts - Midjourney integration:
Tools/GenerateMidjourneyImage.ts(only if env keys exist) - Examples:
Examples/*.png - Artist context:
references/ArtistContext.md
Backends
This adapted skill can use these image backends when credentials are present:
nano-banana-provia Googlefluxvia Replicatenano-bananavia Replicategpt-image-1if OpenAI image credentials exist- background removal via remove.bg if configured
Prefer this order: 1. nano-banana-pro for text-heavy diagrams, editorial illustration, consistency, and best all-around quality 2. flux for stylistic variety 3. nano-banana for faster drafts 4. gpt-image-1 for alternate diagram/text rendering when available
Output Rule
All generated images go to ~/Downloads/ first.
Only after the user approves should they be copied into project directories.
Workflow Routing
Route requests like this and load the matching file with read before doing the work:
| Request | Workflow |
|---|---|
| blog header, editorial illustration, essay art | Workflows/Essay.md |
| technical or architecture diagram | Workflows/TechnicalDiagrams.md |
| Mermaid diagram, flowchart, sequence diagram | Workflows/Mermaid.md |
| comparison, X vs Y | Workflows/Comparisons.md |
| framework, 2x2, quadrant | Workflows/Frameworks.md |
| taxonomy, classification | Workflows/Taxonomies.md |
| timeline, chronology | Workflows/Timelines.md |
| map, territory, landscape | Workflows/Maps.md |
| stat card, metric visual | Workflows/Stats.md |
| data visualization, dashboard | Workflows/D3Dashboards.md or Workflows/Visualize.md |
| annotated screenshot | Workflows/AnnotatedScreenshots.md |
| recipe card, step-by-step | Workflows/RecipeCards.md |
| aphorism, quote card | Workflows/Aphorisms.md |
| comic, sequential panels | Workflows/Comics.md |
| thumbnail | Workflows/YouTubeThumbnailChecklist.md or Workflows/AdHocYouTubeThumbnail.md |
| icon | Workflows/CreatePAIPackIcon.md |
| wallpaper | Workflows/EmbossedLogoWallpaper.md or Workflows/ULWallpaper.md |
| background removal | Workflows/RemoveBackground.md |
| not sure which visual format fits | Workflows/Visualize.md |
Operating Pattern
1. Identify the visual job. 2. Load the matching workflow file. 3. Do the deep concept analysis the workflow asks for, translating any legacy commands into normal reasoning. 4. Inspect relevant local files, content, screenshots, or examples. 5. Produce either:
- the final asset, or
- the strongest generation prompt + execution command + output path, or
- a Mermaid/SVG/ASCII fallback if image generation is not the right tool.
6. Validate the work against the workflow checklist before stopping.
Quality Bar
Aim for publication-quality visuals.
Default standard:
- strong concept
- clear composition
- deliberate typography guidance
- controlled color use
- emotionally appropriate style
- no generic filler aesthetics
- technically correct diagrams
- outputs named clearly in
~/Downloads/
Example Commands
cd ~/.pi/agent/skills/art/Tools
bun run Generate.ts \
--model nano-banana-pro \
--prompt "[PROMPT]" \
--size 2K \
--aspect-ratio 16:9 \
--output ~/Downloads/visual-concept.pngcd ~/.pi/agent/skills/art/Tools
bun run Generate.ts \
--model nano-banana-pro \
--prompt "[PROMPT]" \
--size 2K \
--aspect-ratio 1:1 \
--thumbnail \
--output ~/Downloads/blog-header.pngNotes
- The workflow docs are intentionally rich and opinionated. Use them as creative operating manuals, not as literal harness-specific scripts.
- Read
references/ArtistContext.mdwhen extra aesthetic range or image-prompt quality is needed.
# Copy to ~/.pi/agent/skills/art/.env or ~/.pi/.env if you want Pi-local credentials.
# The adapted tools also fall back to ~/.claude/.env automatically.
# Available backends
GOOGLE_API_KEY=
REPLICATE_API_TOKEN=
REMOVEBG_API_KEY=
OPENAI_API_KEY=
DISCORD_BOT_TOKEN=
MIDJOURNEY_CHANNEL_ID=
# Optional overrides
MIDJOURNEY_DEFAULT_VERSION=6.1
MIDJOURNEY_DEFAULT_QUALITY=1
MIDJOURNEY_DEFAULT_STYLIZE=100
/**
* 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 ${PAI_DIR}/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 ${PAI_DIR}/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');
}
}
}
Artist Agent Context
Role: Visual content creator. Expert at prompt engineering, model selection (Flux 1.1 Pro, Nano Banana, GPT-Image-1), and creating beautiful visuals matching editorial standards.
Model: opus
---
PAI Mission
You are an agent within PAI (Personal AI Infrastructure). Your work feeds the PAI Algorithm — a system that hill-climbs toward Euphoric Surprise (9-10 user ratings).
ISC Participation:
- Your spawning prompt may reference ISC criteria (Ideal State Criteria) — these are your success metrics
- Use
TaskGetto read criteria assigned to you and understand what "done" means - Use
TaskUpdateto mark criteria as completed with evidence - Use
TaskListto see all criteria and overall progress
Timing Awareness: Your prompt includes a ## Scope section defining your time budget:
- FAST → Under 500 words, direct answer only
- STANDARD → Focused work, under 1500 words
- DEEP → Comprehensive analysis, no word limit
Quality Bar: Not just correct — surprisingly excellent.
Artist-Specific: Visual delight contributes to Euphoric Surprise directly. Your creative output is one of the most tangible ways the system produces surprise and joy. Publication-quality is the minimum — aim to exceed expectations.
---
Required Knowledge (Pre-load from Skills)
Core Foundations
- PAI/CoreStack.md - Stack preferences and tooling
- PAI/CONSTITUTION.md - Constitutional principles
Visual Standards
- skills/Media/Art/SKILL.md - Art skill workflows and content types
- skills/Media/Art/Standards.md - Editorial quality standards and aesthetic principles
---
Task-Specific Knowledge
Load these dynamically based on task keywords:
- Diagram/Technical → skills/Media/Art/Workflows/TechnicalDiagrams.md
- Blog/Essay/Header → skills/Media/Art/Workflows/Essay.md
- Video → skills/Media/Art/Workflows/Video.md
- Thumbnail → skills/Media/Art/Workflows/YouTubeThumbnail.md
- Framework → skills/Media/Art/Workflows/Frameworks.md
- Comparison → skills/Media/Art/Workflows/Comparisons.md
---
Key Artistic Principles (from PAI)
These are already loaded via PAI or Art skill - reference, don't duplicate:
- Images skill for all generations (
Skill("images")or direct commands) - Flux 1.1 Pro for highest quality (primary)
- Nano Banana for character consistency / editing
- GPT-Image-1 for technical diagrams with text
- Sora 2 Pro for professional video
- ALL outputs to ~/Downloads/ first (user previews before use)
- Publication-quality baseline (editorial standards)
---
Creative Process
1. Understand context thoroughly (blog post topic, visual role) 2. Choose optimal model based on requirements 3. Craft detailed, nuanced prompt (generic prompts = generic results) 4. Generate using Images skill or direct commands 5. Review quality, suggest refinements if needed 6. Update frequently during generation (every 60-90 seconds)
---
Output Format
## Visual Creation Summary
### Concept & Approach
[Visual strategy and model selection rationale]
### Prompts & Execution
[Prompt engineering details and generation notes]
### Quality Assessment
[How it meets editorial standards]
### Deliverables
[File locations - always ~/Downloads/ for preview]# 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
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 ${PAI_DIR}/skills/Media/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 ${PAI_DIR}/skills/Media/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
/**
* generate - Image Generation CLI
*
* Generate {YOUR_BUSINESS_NAME} 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 ${PAI_DIR}/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 the first available .env file.
* Search order:
* 1. $PAI_DIR/.env (if set)
* 2. This skill's .env
* 3. ~/.pi/.env
* 4. ~/.claude/.env
*/
async function loadEnv(): Promise<void> {
const skillDir = resolve(import.meta.dir, '..');
const envPaths = [
process.env.PAI_DIR ? resolve(process.env.PAI_DIR, '.env') : undefined,
resolve(skillDir, '.env'),
resolve(process.env.HOME!, '.pi', '.env'),
resolve(process.env.HOME!, '.claude', '.env'),
].filter(Boolean) as string[];
for (const envPath of envPaths) {
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();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
return;
} catch {
// Try next candidate.
}
}
}
// ============================================================================
// Types
// ============================================================================
type Model = "flux" | "nano-banana" | "nano-banana-pro" | "gpt-image-1";
type ReplicateSize = "1:1" | "16:9" | "3:2" | "2:3" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "21:9";
type OpenAISize = "1024x1024" | "1536x1024" | "1024x1536";
type GeminiSize = "1K" | "2K" | "4K";
type Size = ReplicateSize | OpenAISize | GeminiSize;
interface CLIArgs {
model: Model;
prompt: string;
size: Size;
output: string;
creativeVariations?: number;
aspectRatio?: ReplicateSize; // For Gemini models
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 remove.bg API
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/generated-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 GEMINI_SIZES: GeminiSize[] = ["1K", "2K", "4K"];
// 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
// ============================================================================
// Skill directory for documentation paths
const SKILL_DIR = resolve(import.meta.dir, '..');
function showHelp(): void {
console.log(`
generate - Image Generation CLI
Generate {YOUR_BUSINESS_NAME} 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
--prompt <text> Image generation prompt (quote if contains spaces)
OPTIONS:
--size <size> Image size/aspect ratio (default: 16:9)
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
Gemini (nano-banana-pro): 1K, 2K, 4K (resolution); aspect ratio inferred from context or defaults to 16:9
--aspect-ratio <ratio> Aspect ratio for Gemini nano-banana-pro (default: 16:9)
Options: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9
--output <path> Output file path (default: /tmp/generated-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 remove.bg API
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 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 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 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
REMOVEBG_API_KEY Required for --remove-bg flag
ERROR CODES:
0 Success
1 General error (invalid arguments, API error, file write error)
MORE INFO:
Documentation: ${SKILL_DIR}/SKILL.md
Source: ${SKILL_DIR}/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-1") {
throw new CLIError(`Invalid model: ${value}. Must be: flux, nano-banana, nano-banana-pro, or gpt-image-1`);
}
parsed.model = value;
i++; // Skip next arg (value)
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)`);
}
// Set model-appropriate default size if not explicitly provided
if (!parsed.size) {
switch (parsed.model) {
case "gpt-image-1":
parsed.size = "1024x1024";
break;
case "nano-banana-pro":
parsed.size = "2K";
break;
default: // flux, nano-banana
parsed.size = "16:9";
break;
}
}
// Validate size based on model
if (parsed.model === "gpt-image-1") {
if (!OPENAI_SIZES.includes(parsed.size as OpenAISize)) {
throw new CLIError(`Invalid size for gpt-image-1: ${parsed.size}. Must be: ${OPENAI_SIZES.join(", ")}`);
}
} 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<void> {
const apiKey = process.env.REMOVEBG_API_KEY;
if (!apiKey) {
throw new CLIError("Missing environment variable: REMOVEBG_API_KEY");
}
console.log("🔲 Removing background with remove.bg API...");
const imageBuffer = await readFile(imagePath);
const formData = new FormData();
formData.append("image_file", new Blob([imageBuffer]), "image.png");
formData.append("size", "auto");
const response = await fetch("https://api.remove.bg/v1.0/removebg", {
method: "POST",
headers: {
"X-Api-Key": apiKey,
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new CLIError(`remove.bg API error: ${response.status} - ${errorText}`);
}
const resultBuffer = Buffer.from(await response.arrayBuffer());
await writeFile(imagePath, resultBuffer);
console.log("✅ Background removed successfully");
}
// ============================================================================
// 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,
},
});
const finalPath = await saveImage(result, 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",
},
});
const finalPath = await saveImage(result, 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 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");
}
// Handle creative variations mode
if (args.creativeVariations && args.creativeVariations > 1) {
console.log(`🎨 Creative Mode: Generating ${args.creativeVariations} variations...`);
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(/\.[^.]+$/, "");
const promises: Promise<string>[] = [];
for (let i = 1; i <= args.creativeVariations; i++) {
const varOutput = `${basePath}-v${i}.png`;
console.log(`Variation ${i}/${args.creativeVariations}: ${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 ${args.creativeVariations} 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);
}
// Remove background if requested (use actual output path)
if (args.removeBg) {
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"; // 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();
#!/usr/bin/env bun
/**
* generate-midjourney-image - Midjourney Image Generation CLI
*
* Generate images using Midjourney via Discord bot integration.
* Follows llcli pattern for deterministic, composable CLI design.
*
* Usage:
* generate-midjourney-image --prompt "..." --aspect-ratio 16:9 --output /tmp/image.png
*
* @see ${PAI_DIR}/skills/art/SKILL.md
*/
import { DiscordBotClient } from '../lib/discord-bot.js';
import { MidjourneyClient, MidjourneyError } from '../lib/midjourney-client.js';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
// ============================================================================
// Environment Loading
// ============================================================================
/**
* Load environment variables from the first available .env file.
* Search order:
* 1. $PAI_DIR/.env (if set)
* 2. This skill's .env
* 3. ~/.pi/.env
* 4. ~/.claude/.env
*/
async function loadEnv(): Promise<void> {
const skillDir = resolve(import.meta.dir, '..');
const envPaths = [
process.env.PAI_DIR ? resolve(process.env.PAI_DIR, '.env') : undefined,
resolve(skillDir, '.env'),
resolve(process.env.HOME!, '.pi', '.env'),
resolve(process.env.HOME!, '.claude', '.env'),
].filter(Boolean) as string[];
for (const envPath of envPaths) {
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();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
return;
} catch {
// Try next candidate.
}
}
}
// ============================================================================
// Types
// ============================================================================
interface CLIArgs {
prompt: string;
aspectRatio: string;
version: string;
stylize: number;
quality: number;
chaos?: number;
weird?: number;
tile: boolean;
output: string;
timeout: number;
}
// ============================================================================
// Configuration
// ============================================================================
const DEFAULTS = {
aspectRatio: '16:9',
version: process.env.MIDJOURNEY_DEFAULT_VERSION || '6.1',
stylize: parseInt(process.env.MIDJOURNEY_DEFAULT_STYLIZE || '100'),
quality: parseInt(process.env.MIDJOURNEY_DEFAULT_QUALITY || '1'),
tile: false,
output: '/tmp/midjourney-image.png',
timeout: 120,
};
// ============================================================================
// 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 MidjourneyError) {
console.error(`\n❌ Midjourney Error: ${error.message}`);
console.error(` Type: ${error.type}`);
if (error.originalPrompt) {
console.error(` Prompt: ${error.originalPrompt}`);
}
if (error.suggestion) {
console.error(` Suggestion: ${error.suggestion}`);
}
process.exit(1);
}
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);
}
// ============================================================================
// Help Text
// ============================================================================
function showHelp(): void {
console.log(`
generate-midjourney-image - Midjourney Image Generation CLI
Generate images using Midjourney via Discord bot integration.
USAGE:
generate-midjourney-image --prompt "<prompt>" [OPTIONS]
REQUIRED:
--prompt <text> Image generation prompt (quote if contains spaces)
OPTIONS:
--aspect-ratio <ratio> Aspect ratio (default: 16:9)
Valid: 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
--version <version> Midjourney version (default: ${DEFAULTS.version})
Valid: 6.1, 6, 5.2, 5.1, 5, niji, niji 6
--stylize <value> Stylization 0-1000 (default: ${DEFAULTS.stylize})
--quality <value> Quality: 0.25, 0.5, 1, 2 (default: ${DEFAULTS.quality})
--chaos <value> Chaos 0-100 (optional)
--weird <value> Weird 0-3000 (optional)
--tile Enable tiling mode (default: false)
--output <path> Output file path (default: ${DEFAULTS.output})
--timeout <seconds> Max wait time (default: ${DEFAULTS.timeout})
ENVIRONMENT VARIABLES:
DISCORD_BOT_TOKEN Discord bot token (required)
MIDJOURNEY_CHANNEL_ID Channel ID for Midjourney (required)
MIDJOURNEY_DEFAULT_VERSION Default Midjourney version
MIDJOURNEY_DEFAULT_QUALITY Default quality setting
MIDJOURNEY_DEFAULT_STYLIZE Default stylize setting
EXAMPLES:
# Standard blog header
generate-midjourney-image \\
--prompt "abstract flowing data streams, minimal shapes, Tokyo Night colors" \\
--aspect-ratio 16:9 \\
--output /tmp/header.png
# High quality square image
generate-midjourney-image \\
--prompt "geometric network visualization, abstract tech concept" \\
--aspect-ratio 1:1 \\
--quality 2 \\
--output /tmp/square.png
# Creative with high stylization
generate-midjourney-image \\
--prompt "flowing organic shapes, data visualization" \\
--stylize 500 \\
--weird 1000
`);
}
// ============================================================================
// Argument Parsing
// ============================================================================
function parseArgs(args: string[]): CLIArgs {
const result: Partial<CLIArgs> = {
aspectRatio: DEFAULTS.aspectRatio,
version: DEFAULTS.version,
stylize: DEFAULTS.stylize,
quality: DEFAULTS.quality,
tile: DEFAULTS.tile,
output: DEFAULTS.output,
timeout: DEFAULTS.timeout,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--help':
case '-h':
showHelp();
process.exit(0);
break;
case '--prompt':
result.prompt = args[++i];
break;
case '--aspect-ratio':
case '--ar':
result.aspectRatio = args[++i];
break;
case '--version':
case '-v':
result.version = args[++i];
break;
case '--stylize':
case '-s':
result.stylize = parseInt(args[++i]);
break;
case '--quality':
case '-q':
result.quality = parseFloat(args[++i]);
break;
case '--chaos':
result.chaos = parseInt(args[++i]);
break;
case '--weird':
result.weird = parseInt(args[++i]);
break;
case '--tile':
result.tile = true;
break;
case '--output':
case '-o':
result.output = args[++i];
break;
case '--timeout':
result.timeout = parseInt(args[++i]);
break;
default:
throw new CLIError(`Unknown argument: ${arg}`);
}
}
// Validate required args
if (!result.prompt) {
throw new CLIError('Missing required argument: --prompt');
}
return result as CLIArgs;
}
// ============================================================================
// Main
// ============================================================================
async function main() {
try {
// Load API keys from ${PAI_DIR}/.env
await loadEnv();
// Parse arguments
const args = parseArgs(process.argv.slice(2));
// Validate environment variables
const botToken = process.env.DISCORD_BOT_TOKEN;
const channelId = process.env.MIDJOURNEY_CHANNEL_ID;
if (!botToken) {
throw new CLIError(
'Missing DISCORD_BOT_TOKEN environment variable. Add it to ~/.pi/agent/skills/art/.env, ~/.pi/.env, or ~/.claude/.env'
);
}
if (!channelId) {
throw new CLIError(
'Missing MIDJOURNEY_CHANNEL_ID environment variable. Add it to ~/.pi/agent/skills/art/.env, ~/.pi/.env, or ~/.claude/.env'
);
}
// Validate Midjourney options
MidjourneyClient.validateOptions({
prompt: args.prompt,
aspectRatio: args.aspectRatio,
version: args.version,
stylize: args.stylize,
quality: args.quality,
chaos: args.chaos,
weird: args.weird,
tile: args.tile,
timeout: args.timeout,
});
console.log('🤖 Midjourney Image Generation');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`Prompt: ${args.prompt}`);
console.log(`Aspect Ratio: ${args.aspectRatio}`);
console.log(`Version: ${args.version}`);
console.log(`Stylize: ${args.stylize}`);
console.log(`Quality: ${args.quality}`);
if (args.chaos) console.log(`Chaos: ${args.chaos}`);
if (args.weird) console.log(`Weird: ${args.weird}`);
if (args.tile) console.log(`Tile: enabled`);
console.log(`Output: ${args.output}`);
console.log(`Timeout: ${args.timeout}s`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// Initialize Discord bot
const discordBot = new DiscordBotClient({
token: botToken,
channelId: channelId,
});
// Initialize Midjourney client
const midjourneyClient = new MidjourneyClient(discordBot);
try {
// Connect to Discord
await discordBot.connect();
// Generate image
const result = await midjourneyClient.generateImage({
prompt: args.prompt,
aspectRatio: args.aspectRatio,
version: args.version,
stylize: args.stylize,
quality: args.quality,
chaos: args.chaos,
weird: args.weird,
tile: args.tile,
timeout: args.timeout,
});
// Download image
await discordBot.downloadImage(result.imageUrl, args.output);
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Success!');
console.log(` Image URL: ${result.imageUrl}`);
console.log(` Saved to: ${args.output}`);
console.log(` Message ID: ${result.messageId}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// Disconnect
await discordBot.disconnect();
process.exit(0);
} catch (error) {
// Ensure we disconnect even on error
await discordBot.disconnect();
throw error;
}
} catch (error) {
handleError(error);
}
}
// Run
main();
#!/usr/bin/env bun
/**
* Abstract Illustration Prompt Generator
*
* ⚠️ DEPRECATED - THIS TOOL USES OLD CHARACTER-BASED SYSTEM
* ⚠️ NEEDS COMPLETE REWRITE FOR ABSTRACT SHAPES/IMPRESSIONS ONLY
* ⚠️ DO NOT USE UNTIL UPDATED
*
* This tool needs to be rewritten to generate prompts using:
* - Abstract shapes and forms (NO characters)
* - Visual motifs (networks, flows, structures, horizons)
* - Composition approaches (centered, horizon, flow, opposition, layered)
*
* Usage (when updated):
* bun run generate-prompt.ts --input essay.md --type essay-illustration
* bun run generate-prompt.ts --input essay.md --type blog-header --format json
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// ============================================================================
// Types
// ============================================================================
type CompositionType = "observation" | "horizon" | "dialogue" | "workshop" | "aura";
type CharacterFocus = "maya" | "kai" | "both";
type TokyoNightColor =
| "Electric Blue"
| "Vivid Purple"
| "Bright Cyan"
| "Neon Green"
| "Warm Yellow"
| "Soft Magenta";
type Human3Motif = "agents" | "networks" | "aura" | "substrates" | "horizons" | "ts_stacks";
type BackgroundType = "sepia" | "dark_tokyo_night";
type OutputFormat = "text" | "json";
interface PromptConfig {
essayTheme: string;
characterFocus: CharacterFocus;
compositionType: CompositionType;
emotionalToneDescription: string;
coreObjectDescription: string;
overallMood: string;
accentColors: TokyoNightColor[];
human3Motifs: Human3Motif[];
backgroundType: BackgroundType;
imageType: "essay-illustration" | "blog-header";
}
interface PromptOutput {
essay_theme: string;
character_focus: CharacterFocus;
composition_type: CompositionType;
emotional_tone_description: string;
core_object_description: string;
overall_mood: string;
accent_colors: string[];
human3_motifs: string[];
image_prompt: string;
suggested_filename?: string;
}
// ============================================================================
// Constants
// ============================================================================
const ART_AESTHETIC_PATH = resolve(
process.env.HOME!,
".claude/PAI/Aesthetic.md"
);
const COLOR_HEX_MAP: Record<TokyoNightColor, string> = {
"Electric Blue": "#7aa2f7",
"Vivid Purple": "#bb9af7",
"Bright Cyan": "#7dcfff",
"Neon Green": "#9ece6a",
"Warm Yellow": "#e0af68",
"Soft Magenta": "#ff007c",
};
const CHARACTER_DESCRIPTIONS = {
maya: `Maya is a young, highly curious girl with a round head, simple short hair, and big round glasses (her signature feature). She has a stick-figure body with thin limbs and a slightly oversized head, with minimal facial features (dots for eyes, simple line for mouth when needed).`,
kai: `{DAIDENTITY.NAME} is a young boy with a slightly oval head, a soft messy hair tuft on top (his signature feature), and NO glasses. He wears a simple t-shirt and shorts or pants. He has a stick-figure body with thin limbs and a slightly oversized head, with minimal facial features.`,
both: `Two recurring child characters: Maya and Kai. Maya is a young, highly curious girl with a round head, simple short hair, and big round glasses. Kai is a young boy with a slightly oval head, a soft messy hair tuft, and a simple t-shirt and shorts or pants. Both have stick-figure bodies with thin limbs and slightly oversized heads, with minimal facial features.`,
};
// ============================================================================
// Helpers
// ============================================================================
function parseArgs(): {
input: string;
type: "essay-illustration" | "blog-header";
format: OutputFormat;
composition?: CompositionType;
character?: CharacterFocus;
colors?: string;
motifs?: string;
} {
const args = process.argv.slice(2);
const parsed: any = {
type: "essay-illustration",
format: "text",
};
for (let i = 0; i < args.length; i += 2) {
const key = args[i].replace(/^--/, "");
const value = args[i + 1];
parsed[key] = value;
}
return parsed;
}
function readEssayContent(path: string): string {
try {
return readFileSync(path, "utf-8");
} catch (error) {
console.error(`Error reading essay file: ${path}`);
throw error;
}
}
function analyzeContent(essayContent: string): {
theme: string;
tone: string;
metaphors: string[];
concepts: string[];
} {
// Simple content analysis
// In a production version, this could use more sophisticated NLP or LLM analysis
const lines = essayContent.split("\n");
const firstParagraph = lines.slice(0, 5).join(" ");
// Extract title (first # line)
const titleLine = lines.find((line) => line.startsWith("# "));
const theme = titleLine?.replace(/^# /, "") || "essay topic";
// Simple tone detection based on keywords
const contentLower = essayContent.toLowerCase();
let tone = "analytical curiosity";
if (contentLower.includes("future") || contentLower.includes("possibility")) {
tone = "cautious wonder about future possibilities";
} else if (contentLower.includes("build") || contentLower.includes("create")) {
tone = "collaborative optimism";
} else if (contentLower.includes("problem") || contentLower.includes("challenge")) {
tone = "analytical focus on challenges and opportunities";
}
return {
theme,
tone,
metaphors: [], // Could be extracted with more sophisticated analysis
concepts: [], // Could be extracted with more sophisticated analysis
};
}
function selectComposition(
essayContent: string,
override?: CompositionType
): CompositionType {
if (override) return override;
const contentLower = essayContent.toLowerCase();
if (contentLower.includes("future") || contentLower.includes("horizon")) {
return "horizon";
} else if (contentLower.includes("together") || contentLower.includes("collaborate")) {
return "dialogue";
} else if (contentLower.includes("build") || contentLower.includes("create")) {
return "workshop";
} else if (contentLower.includes("personal") || contentLower.includes("context")) {
return "aura";
}
return "observation"; // Default
}
function selectCharacter(
compositionType: CompositionType,
override?: CharacterFocus
): CharacterFocus {
if (override) return override;
if (compositionType === "dialogue") return "both";
if (compositionType === "workshop") return "kai";
if (compositionType === "observation") return "maya";
return "both"; // Default
}
function selectColors(
essayContent: string,
override?: string
): TokyoNightColor[] {
if (override) {
return override.split(",").map((c) => c.trim() as TokyoNightColor);
}
const contentLower = essayContent.toLowerCase();
if (contentLower.includes("security") || contentLower.includes("privacy")) {
return ["Vivid Purple"];
} else if (contentLower.includes("tool") || contentLower.includes("productivity")) {
return ["Bright Cyan"];
} else if (contentLower.includes("human") || contentLower.includes("growth")) {
return ["Neon Green"];
}
return ["Electric Blue"]; // Default for AI/tech
}
function selectMotifs(
essayContent: string,
override?: string
): Human3Motif[] {
if (override) {
return override.split(",").map((m) => m.trim() as Human3Motif);
}
const motifs: Human3Motif[] = [];
const contentLower = essayContent.toLowerCase();
if (contentLower.includes("agent") || contentLower.includes("ai")) {
motifs.push("agents");
}
if (contentLower.includes("network") || contentLower.includes("connect")) {
motifs.push("networks");
}
if (contentLower.includes("future") || contentLower.includes("horizon")) {
motifs.push("horizons");
}
if (contentLower.includes("personal") || contentLower.includes("context")) {
motifs.push("aura");
}
return motifs.slice(0, 2); // Max 2 motifs
}
function buildVisualMetaphor(
essayContent: string,
compositionType: CompositionType,
characterFocus: CharacterFocus
): string {
// This is a simplified version. In production, this would use more sophisticated
// content analysis to generate specific visual metaphors from essay content.
const analysis = analyzeContent(essayContent);
let metaphor = "";
switch (compositionType) {
case "observation":
metaphor = `${characterFocus === "maya" ? "Maya" : "Kai"} positioned in the left quarter of the frame, small and observing with ${analysis.tone}, looking at a large visual element on the right that represents the core concept of ${analysis.theme}`;
break;
case "horizon":
metaphor = `${characterFocus === "both" ? "Maya and Kai" : characterFocus === "maya" ? "Maya" : "Kai"} in the foreground, facing a wide distant horizon filled with tiny elements representing future possibilities related to ${analysis.theme}`;
break;
case "dialogue":
metaphor = `Maya and {DAIDENTITY.NAME} positioned with space between them, interacting with a shared element or concept in the center, representing different perspectives on ${analysis.theme}`;
break;
case "workshop":
metaphor = `${characterFocus === "kai" ? "Kai" : "Both Maya and Kai"} actively building or creating, with elements spreading horizontally showing the process of making something related to ${analysis.theme}`;
break;
case "aura":
metaphor = `${characterFocus === "maya" ? "Maya" : characterFocus === "kai" ? "Kai" : "The character"} surrounded by a soft, translucent aura bubble containing tiny symbolic icons representing aspects of ${analysis.theme}`;
break;
}
return metaphor;
}
function buildMotifsDescription(motifs: Human3Motif[]): string {
if (motifs.length === 0) return "";
const descriptions: string[] = [];
for (const motif of motifs) {
switch (motif) {
case "agents":
descriptions.push("tiny cute pill-shaped agent robots");
break;
case "networks":
descriptions.push("thin network lines connecting small nodes");
break;
case "aura":
descriptions.push(
"soft aura bubbles around people or robots with tiny symbolic icons like hearts, book-shapes, leaves, or stars (icons must be purely visual and contain no letters or numbers)"
);
break;
case "substrates":
descriptions.push("horizontal platform layers suggesting infrastructure");
break;
case "horizons":
descriptions.push("distant horizon line filled with tiny silhouettes");
break;
case "ts_stacks":
descriptions.push("stacks of thin blank rectangular sheets");
break;
}
}
return `Optionally include Human 3.0 motifs that fit the essay: ${descriptions.join(", ")}.`;
}
// ============================================================================
// Prompt Generation
// ============================================================================
function generatePrompt(config: PromptConfig): string {
const {
essayTheme,
characterFocus,
compositionType,
emotionalToneDescription,
coreObjectDescription,
overallMood,
accentColors,
human3Motifs,
backgroundType,
imageType,
} = config;
// Build color description
const colorDescriptions = accentColors
.map((color) => `${color} ${COLOR_HEX_MAP[color]}`)
.join(" and ");
// Build character descriptions
const characterDesc = CHARACTER_DESCRIPTIONS[characterFocus];
// Build motifs description
const motifsDesc = buildMotifsDescription(human3Motifs);
// Background description
const backgroundDesc =
backgroundType === "sepia"
? "Soft sepia-toned paper background with lots of empty space."
: "Dark gradient background transitioning from #1a1b26 to #24283b.";
// Base prompt
let prompt = `Minimal Tokyo Night–inspired illustration for ${imageType === "blog-header" ? "an {YOUR_BUSINESS_NAME} blog post" : "an essay"} about ${essayTheme}.
${backgroundDesc} Thin, slightly imperfect deep navy linework and flat color fills only, no shading. Tokyo Night–inspired accent color${accentColors.length > 1 ? "s" : ""} ${colorDescriptions} used sparingly.
${characterDesc}
Show ${characterFocus === "both" ? "Maya and Kai" : characterFocus} in a ${compositionType} scene${imageType === "blog-header" ? " optimized for horizontal 16:9 composition" : ""}. ${emotionalToneDescription.charAt(0).toUpperCase() + emotionalToneDescription.slice(1)}, interacting with ${coreObjectDescription}.
${motifsDesc}
The overall mood should be ${overallMood}. No text, no letters, no numbers, and no labels anywhere in the image.`;
// Add blog header specifications if applicable
if (imageType === "blog-header") {
prompt += `
=== BLOG HEADER SPECIFICATIONS ===
Output format: PNG, 1536x1024 (16:9 landscape for blog header)
Horizontal composition optimized for wide format
Primary focus in upper two-thirds of frame
Maximum quality settings (95% quality)
Editorial cover image quality like The Atlantic or New Yorker or New York Times`;
}
return prompt;
}
// ============================================================================
// Main
// ============================================================================
function main() {
const args = parseArgs();
if (!args.input) {
console.error("Usage: bun run generate-prompt.ts --input <essay.md> [options]");
console.error("\nOptions:");
console.error(" --type essay-illustration | blog-header (default: essay-illustration)");
console.error(" --format text | json (default: text)");
console.error(" --composition observation | horizon | dialogue | workshop | aura");
console.error(" --character maya | kai | both");
console.error(' --colors "Electric Blue,Neon Green" (comma-separated)');
console.error(' --motifs "agents,networks" (comma-separated)');
process.exit(1);
}
// Read essay content
const essayContent = readEssayContent(args.input);
// Analyze content
const analysis = analyzeContent(essayContent);
// Select visual elements
const compositionType = selectComposition(essayContent, args.composition);
const characterFocus = selectCharacter(compositionType, args.character);
const accentColors = selectColors(essayContent, args.colors);
const human3Motifs = selectMotifs(essayContent, args.motifs);
// Build visual metaphor
const coreObjectDescription = buildVisualMetaphor(
essayContent,
compositionType,
characterFocus
);
// Build config
const config: PromptConfig = {
essayTheme: analysis.theme,
characterFocus,
compositionType,
emotionalToneDescription: `They are ${analysis.tone}`,
coreObjectDescription,
overallMood: analysis.tone.split(" ").slice(0, 2).join(" "), // Simplified mood
accentColors,
human3Motifs,
backgroundType: "sepia",
imageType: args.type,
};
// Generate prompt
const imagePrompt = generatePrompt(config);
// Output
if (args.format === "json") {
const output: PromptOutput = {
essay_theme: analysis.theme,
character_focus: characterFocus,
composition_type: compositionType,
emotional_tone_description: config.emotionalToneDescription,
core_object_description: coreObjectDescription,
overall_mood: config.overallMood,
accent_colors: accentColors,
human3_motifs: human3Motifs,
image_prompt: imagePrompt,
suggested_filename: analysis.theme
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") + ".png",
};
console.log(JSON.stringify(output, null, 2));
} else {
console.log(imagePrompt);
}
}
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
}
}
Ad-hoc YouTube Thumbnail Workflow
Generate complete YouTube thumbnails from content input with dramatic tech backgrounds and AI-generated headshots.
Voice Notification
curl -s -X POST ${PAI_NOTIFY_URL:-} \
-H "Content-Type: application/json" \
-d '{"message": "Running the AdHocYouTubeThumbnail workflow in the Art skill to create thumbnails"}' \
> /dev/null 2>&1 &Running AdHocYouTubeThumbnail in Art...
---
Explicit Criteria
1. Dynamic Headshot - FACE ONLY
- Fresh AI-generated each time using Nano Banana Pro with reference images
- Reference image is used for likeness, NOT the actual headshot
- Position is dynamic: left, center, or right (based on content/preference)
- 🚨 FACE ONLY: Forehead to chin, ear to ear - NO shoulders, NO neck, NO body
- Face fills 95% of the image area (ComposeThumbnail auto-crops)
- Transparent background: Must run RemoveBg after generation
- 🚨 MUST VARY between thumbnails (see Variation Requirements below)
2. Dramatic Tech Background
- Style: Futuristic, sci-fi aesthetic (hexagonal circuits, glowing edges, 3D depth)
- Colors: Dark with cyan/blue/purple neon accents (Tokyo Night palette)
- No text, no people in the background - pure abstract tech art
- Examples: Blade Runner, Tron, circuit board patterns with glow
3. Text - BILLBOARD STYLE
- Title: Up to 6 words, CAPITALIZED, CYAN by default (vibrant, not white)
- Subtitle: Up to 12 words, CAPITALIZED, white text
- 🚨 TEXT FILLS THE SPACE - takes up most of available area opposite headshot
- BOLD STROKE OUTLINE (4px title, 3px subtitle) - visible at 320px thumbnail size
- Visually centered in safe zone (NEVER overlaps headshot)
- Grouped as a unit - title and subtitle together as text block
4. Colored Border
- Tokyo Night purple (#bb9af7) default
- 16px width around entire thumbnail
- Creates professional framing
---
Output Specifications
| Element | Value |
|---|---|
| Canvas | 1280x720 px |
| Border | 16px #bb9af7 (Tokyo Night purple) |
| Headshot | FACE ONLY (~688px height), auto-cropped (no shoulders/body) |
| Title | 100pt Helvetica-Bold, 4px black stroke outline |
| Subtitle | 50pt Helvetica-Bold, 3px black stroke outline |
| Title color | CYAN (#7dcfff) by default - NEVER plain white |
| Subtitle color | White (#FFFFFF) for contrast |
| Text position | FILLS safe zone opposite headshot (NEVER overlaps) |
| Background | Dramatic futuristic tech art |
| Fresh headshot | MANDATORY - generate new WITH VARIATION each time |
| 320x180 test | MANDATORY - must be readable at YouTube grid size |
Text Color Presets (--title-color, --subtitle-color)
| Name | Hex | Use |
|---|---|---|
| cyan | #7dcfff | DEFAULT - Tech, futuristic |
| white | #FFFFFF | High contrast (subtitle default) |
| purple | #bb9af7 | Matches border |
| blue | #7aa2f7 | Professional |
| magenta | #ff007c | Bold, attention |
| yellow | #e0af68 | Warning, highlight |
| green | #9ece6a | Success, growth |
| orange | #ff9e64 | Energy, urgency |
| red | #f7768e | Alert, danger |
---
Step 1: Content Analysis
Extract title and subtitle from input content.
Input Types
- Script or article text
- URL (fetch and analyze)
- Topic description
- Video outline
Extraction Prompt
Analyze this content and extract:
1. TITLE (max 6 words): The attention-grabbing hook
2. SUBTITLE (max 12 words): The value promise or context
Guidelines:
- Use power words: "SECRET", "HIDDEN", "REAL", "TRUTH", "WHY", "HOW"
- Create curiosity gaps
- Be specific over generic
- Make a bold claim or promise
Content: [INPUT]---
Step 2: Background Generation
Generate dramatic futuristic tech background.
Background Prompt Template
Dramatic futuristic technology background. Dark hexagonal circuit board pattern
with glowing cyan/blue neon edge lighting. 3D depth perspective. Metallic dark
grey hexagons with embedded circuit patterns. Glowing cyan (#7dcfff) and purple
(#bb9af7) edge highlights. Deep shadows, high contrast. Sci-fi aesthetic like
Blade Runner or Tron. Abstract technology, no text, no people. Dark moody
atmosphere with electric blue glow accents.
Topic context: [EXTRACTED TOPIC]Generate Command
bun run ${PAI_DIR}/skills/Art/Tools/Generate.ts \
--model nano-banana-pro \
--prompt "[BACKGROUND PROMPT]" \
--size 2K \
--aspect-ratio 16:9 \
--output ~/Downloads/yt-bg-$(date +%Y%m%d-%H%M%S).png---
Step 3: Headshot Generation
🚨 MANDATORY: Generate a FRESH, VARIED, FACE-ONLY headshot EVERY time.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ FACE ONLY: Forehead to chin, ear to ear ⚠️
⚠️ NO shoulders, NO neck, NO body visible ⚠️
⚠️ If shoulders/body visible → REGENERATE IMMEDIATELY ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Headshot Variation Requirements
For each thumbnail, RANDOMLY select ONE from each category:
Angle:
- Straight-on, looking directly at camera
- Slight 3/4 turn, face angled 15 degrees to the right
- Head tilted slightly to the right
Expression:
- Confident, authoritative
- Contemplative, thoughtful intensity
- Focused, direct engagement
Lighting:
- Soft diffused key light
- Dramatic side lighting with shadow
- Rembrandt lighting pattern
Base Headshot Requirements (always include)
- 🚨 FACE ONLY - forehead to chin, ear to ear
- NO shoulders, NO neck, NO body - face fills entire frame
- Pure black background (for easy removal)
- Full beard along jawline, NO mustache (clean-shaven upper lip)
- Face fills 95% of image area
Example FACE-ONLY Prompts
Variation A (confident, straight-on):
Extreme close-up of the subject's FACE ONLY. Frame shows forehead to chin, ear to ear.
Absolutely NO shoulders, NO neck, NO body visible. Face fills entire image.
Confident, authoritative expression - NOT smiling. Looking directly at camera.
Pure black background. Full beard along jawline with clean-shaven upper lip.
Soft diffused key lighting. Ultra-tight crop on face only.Variation B (contemplative, 3/4 angle):
Extreme close-up of the subject's FACE ONLY. Frame shows forehead to chin, ear to ear.
Absolutely NO shoulders, NO neck, NO body visible. Face fills entire image.
Contemplative, thoughtful expression with subtle intensity - NOT smiling.
Face turned 15 degrees to the right, slight 3/4 angle.
Pure black background. Full beard along jawline with clean-shaven upper lip.
Dramatic side lighting creating depth. Ultra-tight crop on face only.Variation C (focused, head tilt):
Extreme close-up of the subject's FACE ONLY. Frame shows forehead to chin, ear to ear.
Absolutely NO shoulders, NO neck, NO body visible. Face fills entire image.
Focused, direct engagement expression - NOT smiling. Head tilted slightly.
Pure black background. Full beard along jawline with clean-shaven upper lip.
Rembrandt lighting pattern. Looking at camera. Ultra-tight crop on face only.Generate Command
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
bun ${PAI_DIR}/skills/Headshot/Tools/Headshot.ts \
--prompt "[FACE-ONLY HEADSHOT PROMPT]" \
--reference ${PAI_DIR}/skills/Headshot/Examples/reference.png \
--reference ${PAI_DIR}/skills/Headshot/Examples/studio-glasses-style.png \
--reference ${PAI_DIR}/skills/Headshot/Examples/clean-smile.png \
--size 2K \
--aspect-ratio 1:1 \
--output ~/Downloads/yt-headshot-${TIMESTAMP}.pngNote: Using 1:1 aspect ratio forces tighter face crop. ComposeThumbnail will also auto-crop to remove any remaining body.
Remove Background
bun ${PAI_DIR}/skills/PAI/Tools/RemoveBg.ts ~/Downloads/yt-headshot-${TIMESTAMP}.png---
Step 4: Composition
Composite all elements using ComposeThumbnail tool.
Compose Command
bun ${PAI_DIR}/skills/Art/Tools/ComposeThumbnail.ts \
--background ~/Downloads/yt-bg-${TIMESTAMP}.png \
--headshot ~/Downloads/yt-headshot-${TIMESTAMP}.png \
--title "[TITLE]" \
--subtitle "[SUBTITLE]" \
--title-color [cyan|purple|magenta|white|etc] \
--position [left|center|right] \
--output ~/Downloads/yt-thumbnail-${TIMESTAMP}.pngPosition Logic
- left: Headshot on left, text centered on right half
- center: Headshot centered, title at top, subtitle at bottom
- right: Headshot on right, text centered on left half
Text Positioning (automatic)
- For left/right: Text block (title + subtitle) centered vertically in opposite half
- For center: Title at top edge, subtitle at bottom edge
- Text uses black stroke outline for readability (no black boxes)
---
Step 5: Quality Validation
🚨 MANDATORY: ALL checks must pass before presenting to the user.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ THE 320x180 TEST IS MANDATORY ⚠️
⚠️ If text isn't readable at thumbnail size → FAIL ⚠️
⚠️ If it looks like ass at any size → FAIL ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Quality Gates (ALL MUST PASS)
| # | Check | Pass Criteria |
|---|---|---|
| 1 | Dimensions | Exactly 1280x720 |
| 2 | FACE-ONLY headshot | NO shoulders, NO neck, NO body visible |
| 3 | Face fills frame | Face is 90%+ of headshot area |
| 4 | Text fills space | Title is large, bold, FILLS the text zone |
| 5 | Text color | CYAN or vibrant - NOT plain white |
| 6 | Stroke visible | 4px title / 3px subtitle - visible at 320px |
| 7 | No overlap | Text entirely in its safe zone |
| 8 | Variation | Visibly different from previous generation |
| 9 | 320x180 readability | Text readable at YouTube grid size |
| 10 | Overall | Professional, billboard-quality appearance |
Validation Commands
# 1. Verify dimensions
magick identify -format "%wx%h" ~/Downloads/yt-thumbnail-${TIMESTAMP}.png
# Expected: 1280x720
# 2. Open for visual inspection at full size
open ~/Downloads/yt-thumbnail-${TIMESTAMP}.png
# Confirm: Face only (no body), text fills space, cyan color visible
# 3. 🚨 MANDATORY: Test at YouTube thumbnail size
magick ~/Downloads/yt-thumbnail-${TIMESTAMP}.png -resize 320x180 /tmp/yt-preview.png
open /tmp/yt-preview.png
# Confirm: Title READABLE, face RECOGNIZABLE, colors POP
# If you can't read the title at 320x180 → FAILFailure Response
If ANY check fails: 1. DO NOT present to the user 2. Identify the specific failure 3. Fix the issue:
- Body visible → Regenerate headshot with FACE-ONLY prompt
- Text too small → Already fixed (100pt/50pt)
- Text not visible at 320x180 → Check color/stroke
- Text overlapping → Check positioning
4. Re-run composition 5. Re-verify ALL checks including 320x180 test 6. Only present when ALL checks pass
Quality Standards
- Thumbnail is a BILLBOARD - text must dominate, face must dominate
- DO NOT present output that looks broken, garbled, or unprofessional
- Iterate until it matches ALL criteria
- If it looks like ass, fix it before showing the user
- The 320x180 test is the ultimate validation - that's what YouTube shows
---
Quick Reference
Tokyo Night Colors
Purple (border): #bb9af7
Cyan (accents): #7dcfff
Blue (accents): #7aa2f7
Dark base: #1a1b26Workflow Summary
1. ANALYZE content → Extract TITLE + SUBTITLE
2. GENERATE background → Dramatic tech art (Nano Banana Pro)
3. GENERATE headshot → FACE-ONLY (1:1 aspect), WITH VARIATION + RemoveBg
4. COMPOSE → ComposeThumbnail.ts (auto-crops body, cyan text, 100pt title)
5. VALIDATE → ALL gates including 320x180 readability testPhilosophy
The thumbnail is a BILLBOARD, not a document.
- FACE dominates one side
- TEXT FILLS the other side
- Must be readable at 320x180
- Every generation is visibly different
Output Location
All outputs: ~/Downloads/yt-thumbnail-{timestamp}.png
Annotated Screenshots Workflow
Real screenshots with hand-drawn editorial annotations, arrows, and highlights using brand aesthetic.
Voice Notification
curl -s -X POST ${PAI_NOTIFY_URL:-} \
-H "Content-Type: application/json" \
-d '{"message": "Running the AnnotatedScreenshots workflow in the Art skill to annotate images"}' \
> /dev/null 2>&1 &Running AnnotatedScreenshots in Art...
---
Creates ANNOTATED SCREENSHOTS — actual UI screenshots or code snippets with hand-drawn purple/teal commentary overlays.
---
Purpose
Annotated screenshots combine real artifacts (UI, code, data) with hand-drawn editorial commentary. This hybrid real + illustrated approach adds voice and insights directly onto actual examples.
Use this workflow for:
- Product reviews with annotated screenshots
- Technical tutorials pointing out UI elements
- UX critiques with visual commentary
- Code reviews with illustrated notes
- "THIS IS THE PROBLEM" arrows and callouts
---
Visual Aesthetic: Real + Hand-Drawn Overlay
Think: Screenshot with hand-drawn arrows, circles, and annotations in editorial voice
Core Characteristics
1. Real foundation — Actual screenshot or code snippet (not illustrated) 2. Hand-drawn overlay — Arrows, circles, highlights, callouts in editorial style 3. Typography mix — Real UI text + hand-lettered annotations 4. Color accents — Purple/teal for annotations against real screenshot 5. Editorial voice — Annotations sound like smart commentary 6. Editorial style — Maintains hand-drawn, gestural linework for overlays 7. Functional clarity — Annotations enhance understanding, not just decoration
---
Color System for Annotated Screenshots
Real Screenshot Layer
Original colors preserved (screenshot remains unmodified)
OR
Slightly desaturated/faded to make annotations popAnnotation Overlay
Deep Purple #4A148C — Primary annotations (important callouts)
Deep Teal #00796B — Secondary annotations (supporting notes)
Black #000000 — Arrows, circles, underlines
Charcoal #2D2D2D — Annotation text (when not purple/teal)Strategy
- Screenshot slightly faded/grayed (80% opacity) to let annotations stand out
- Purple for critical annotations ("THIS IS THE ISSUE")
- Teal for helpful context ("here's how it works")
- Black for structural annotations (arrows, circles, boxes)
---
🚨 MANDATORY WORKFLOW STEPS
Step 1: Prepare Screenshot
Get the base image:
1. Capture screenshot:
- Take actual screenshot of UI, code, website, etc.
- Crop to relevant area
- Ensure text is readable
2. Process screenshot:
- Optionally desaturate slightly (makes overlays pop)
- Resize if needed for clarity
- Save as base image
Output:
SCREENSHOT SOURCE: [Path to screenshot file]
SUBJECT: [What the screenshot shows]
KEY AREAS TO ANNOTATE:
- Area 1: [Description] — [What to call out]
- Area 2: [Description] — [What to call out]
...---
Step 2: Plan Annotations
Identify what to mark:
1. What are you calling attention to?
- Problem areas
- Good examples
- Workflow steps
- Hidden features
2. What type of annotation for each?
- Arrow pointing to element
- Circle/box highlighting region
- Underline or bracket
- Callout with note
3. What's the commentary?
- "this is the problem"
- "should be here instead"
- "genius design"
- "completely missed the point"
Output:
ANNOTATIONS TO ADD:
1. [Area/Element]:
- Type: [Arrow / Circle / Box / Underline]
- Color: [Purple / Teal / Black]
- Text: "[Your commentary]"
- Position: [Where on screenshot]
2. [Area/Element]:
- Type: [Annotation type]
- Color: [Color choice]
- Text: "[Commentary]"
- Position: [Location]
...
EMPHASIS:
- Purple (critical): [Which annotations]
- Teal (helpful): [Which annotations]---
Step 3: Construct Prompt
Note: This workflow is different - you're adding overlays to an existing image. You may need to:
- Upload screenshot as reference image
- Generate hand-drawn annotation layer separately
- Composite in image editor
OR
- Use prompt to describe "screenshot with annotations" if model can render both
Prompt Template (If Generating Combined Image)
Real UI screenshot with hand-drawn editorial annotations overlay.
STYLE: Actual screenshot with imperfect hand-drawn arrows, circles, and notes on top
SCREENSHOT BASE:
- [Describe the screenshot content, e.g.: "ChatGPT interface showing conversation"]
- Slightly desaturated/faded (80% opacity) to let annotations stand out
- All original text and UI elements clearly visible
ANNOTATION OVERLAY STYLE:
- Hand-drawn arrows, circles, underlines in editorial style
- Variable stroke weight, wobbly imperfect lines
- Gestural quality (not polished vectors)
- Hand-lettered annotation text
TYPOGRAPHY FOR ANNOTATIONS (Advocate Italic):
- Font: Advocate condensed italic (hand-lettered style)
- Size: Readable against screenshot
- Color: Purple #4A148C or Teal #00796B for emphasis
- Style: Editorial voice — casual, direct, insightful
ANNOTATIONS TO ADD:
[List each annotation, e.g.:]
1. PURPLE ARROW pointing to [UI element]:
- Hand-drawn wobbly arrow in Purple (#4A148C)
- Text annotation: "*THIS IS THE PROBLEM*"
- Thick stroke, clear pointing direction
- Position: [Location on screenshot]
2. TEAL CIRCLE around [UI area]:
- Hand-drawn imperfect circle in Teal (#00796B)
- Text annotation: "*notice this pattern*"
- Slightly wobbly outline
- Position: [Area to highlight]
3. BLACK UNDERLINE beneath [text]:
- Hand-drawn wavy underline in Black (#000000)
- Emphasizes existing screenshot text
- No additional annotation needed
4. PURPLE CALLOUT box:
- Hand-drawn box with arrow pointing to [element]
- Text: "*should have been here instead*"
- Purple (#4A148C) box outline and text
- Position: [Near relevant UI element]
[etc. for all annotations]
COLOR USAGE:
- Screenshot: Original colors (or slightly desaturated)
- Purple (#4A148C): Critical annotations, "this is wrong" callouts
- Teal (#00796B): Helpful context, "here's why" explanations
- Black (#000000): Structural annotations (arrows, circles, underlines)
- Charcoal (#2D2D2D): General annotation text when not emphasized
CRITICAL REQUIREMENTS:
- Screenshot remains readable and recognizable
- Hand-drawn annotations clearly overlay (not integrated into UI)
- Annotations enhance understanding, point out insights
- Variable stroke weight, imperfect human-drawn quality
- Editorial voice in text ("*this*", not formal descriptions)
- Strategic color (not every annotation needs color)
- No gradients on annotations
Optional: Sign small in bottom corner in charcoal (#2D2D2D).Alternative: Composite Workflow
If generating combined image is difficult:
1. Generate annotation layer separately:
- Transparent background
- Only arrows, circles, text annotations
- Match screenshot dimensions
2. Composite in image editor:
- Layer screenshot (bottom)
- Layer annotations (top)
- Adjust annotation opacity if needed
---
Step 4: Determine Aspect Ratio
Match screenshot aspect ratio:
- Screenshot is 16:9 → Use 16:9
- Screenshot is vertical phone UI → Use 9:16
- Screenshot is square → Use 1:1
- Screenshot is wide desktop → Use 21:9
Preserve original screenshot proportions
---
Step 5: Execute Generation
Option A: Generate combined (if model supports):
bun run ${PAI_DIR}/skills/art/Tools/Generate.ts \
--model nano-banana-pro \
--reference-image /path/to/screenshot.png \
--prompt "[ANNOTATION PROMPT]" \
--size 2K \
--aspect-ratio [match screenshot] \
--output /path/to/annotated.pngOption B: Generate annotation layer, then composite manually
Immediately Open:
open /path/to/annotated.png---
Step 6: Validation (MANDATORY)
Must Have
- [ ] Screenshot readable — Original content clearly visible
- [ ] Annotations clear — Arrows/circles/text obviously hand-drawn overlays
- [ ] Editorial voice — Annotations sound like smart commentary
- [ ] Strategic pointing — Annotations highlight key insights, not random decoration
- [ ] Color emphasis — Purple on critical, teal on helpful
- [ ] Hand-drawn quality — Wobbly arrows, imperfect circles, gestural
- [ ] Functional value — Annotations actually enhance understanding
Must NOT Have
- [ ] Unreadable screenshot
- [ ] Polished digital annotation look
- [ ] Generic corporate callouts ("Feature A")
- [ ] Too many annotations (cluttered)
- [ ] Formal voice (should be casual, direct)
- [ ] Perfect straight arrows or circles
If Validation Fails
| Problem | Fix |
|---|---|
| Screenshot too dark | Lighten/desaturate screenshot layer, increase annotation contrast |
| Annotations too polished | Emphasize "hand-drawn wobbly arrows, imperfect circles, gestural sketch" |
| Voice too formal | Rewrite annotations in casual voice: "this right here" |
| Can't tell what's being pointed out | Larger/bolder arrows, clearer pointing direction |
| Too cluttered | Reduce annotations to 3-5 key insights only |
| Looks corporate | Reference "editorial annotation style, smart person's markup, hand-drawn notes" |
---
Example Use Cases
Example 1: ChatGPT UI Critique
- Screenshot: ChatGPT conversation interface
- Annotations:
- Purple arrow: "this prompt engineering is bad"
- Teal circle: "notice how it avoided the question"
- Black underline: Emphasizing problematic output
- Aspect: 16:9
Example 2: Code Review
- Screenshot: Python code snippet
- Annotations:
- Purple box: "bottleneck right here"
- Teal arrow: "clever use of list comprehension"
- Black circle: Highlighting security issue
- Aspect: 1:1 (code block)
Example 3: UX Flow Breakdown
- Screenshot: Mobile app workflow (multiple screens)
- Annotations:
- Numbered purple arrows showing flow
- Teal notes on each step: "where users drop off"
- Black boxes highlighting UI elements
- Aspect: 9:16 (vertical phone layout)
---
Quick Reference
Annotated Screenshot Formula:
1. Prepare screenshot (capture, crop, optionally desaturate)
2. Plan annotations (what to mark, commentary, colors)
3. Construct prompt OR composite manually
4. Match screenshot aspect ratio
5. Generate/composite annotations
6. Validate for clarity and voiceColor Strategy:
- Screenshot: Original colors (or slightly faded)
- Purple: Critical annotations
- Teal: Helpful context
- Black: Structural marks
Voice:
- Casual, direct, editorial commentary
- "this is the issue" not "Area A shows problem"
---
The workflow: Prepare → Plan → Annotate → Generate → Validate → Complete
Related skills
FAQ
Where do generated images go?
All generated images go to ~/Downloads/ first for review, and are copied into project directories only after approval.
What if image generation is unavailable?
It still produces a concept, a production-ready prompt, model/size recommendations, and a Mermaid/SVG/ASCII fallback.