
Happy Image Gen
- 155 installs
- 303 repo stars
- Updated April 20, 2026
- iamzhihuix/happy-claude-skills
Create UI mockups, icons, hero art, and marketing visuals inside Claude Code while building pages and apps without switching to external design tools.
About
happy-image-gen from iamzhihuix/happy-claude-skills enables Claude Code to generate images on demand for product UIs, docs, and campaigns. It targets build-time frontend needs when teams want fast, agent-native visuals instead of separate design pipelines or manual asset sourcing.
- In-agent image generation
- UI and marketing asset support
- Happy Claude skills integration
- Cuts external design tool context switching
- Speeds visual iteration loops
Happy Image Gen by the numbers
- 155 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #694 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzhihuix/happy-claude-skills --skill happy-image-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 155 |
|---|---|
| repo stars | ★ 303 |
| Last updated | April 20, 2026 |
| Repository | iamzhihuix/happy-claude-skills ↗ |
What it does
Create UI mockups, icons, hero art, and marketing visuals inside Claude Code while building pages and apps without switching to external design tools.
Files
happy-image-gen
Generates still images across 8 providers through one CLI: bun scripts/main.ts .... The same CLI handles text-to-image and image-to-image (reference-driven) edits.
Quick usage
bun scripts/main.ts --prompt "A calico cat on green grass, cinematic light" --ar 16:9 --image ./out.pngWhen to invoke this skill
Invoke this skill whenever the user:
- Asks to generate, create, draw, render, illustrate, or synthesize an image from text.
- Asks to restyle or transform an existing image they provide a path to.
- Names any image-generation model (DALL·E, gpt-image, Flux, SDXL, Gemini Image, Imagen, Seedream, Kolors, Wanx, Stable Diffusion) without specifying 即梦/Dreamina/Jimeng.
Route to happy-dreamina instead when the user explicitly names 即梦, Jimeng, or the dreamina CLI.
Step 0: Preflight (BLOCKING — run before any generation)
Run these checks:
1. Locate EXTEND.md config. Check in order:
./.happy-skills/happy-image-gen/EXTEND.md(project)$XDG_CONFIG_HOME/happy-skills/happy-image-gen/EXTEND.md~/.happy-skills/happy-image-gen/EXTEND.md(user)
If none exist, run bun scripts/main.ts --setup and follow references/config/first-time-setup.md to create one. Do not proceed to generation until the user has at least one provider configured.
2. Verify a provider is usable. Confirm either an env var is set (e.g., OPENAI_API_KEY) or EXTEND.md references an api_key_env / api_key_source that resolves. If nothing resolves, loop back to setup.
3. Verify Bun is available. Run command -v bun. If missing, fall back to npx -y bun scripts/main.ts ....
Step 1: Choose a provider
Pick in this order of preference:
1. --provider <id> explicitly passed by the user. 2. The default_provider in EXTEND.md. 3. The first provider whose API key is present in the environment. Priority when auto-detecting: openai → google → replicate → stability → fal → ark → bailian → siliconflow.
See references/providers.md for each provider's required env vars, default models, and strengths (e.g., prefer google for text-in-image, replicate for Flux-family photorealism, ark for Chinese text fidelity).
Step 2: Fill in parameters
- `--prompt`: the user's full request, trimmed. Always double-quote.
- `--ar`: aspect ratio —
1:1/16:9/9:16/3:4/4:3. Seereferences/aspect_ratio_map.mdfor how each provider interprets this. - `--quality`:
draft(fastest + cheapest),hd(default), orultra(4K-class, slower). - `--ref <path>`: repeat for multiple reference images. Not every provider supports this — see providers.md.
- `--model`: override the default model for the chosen provider. Omit unless the user asked for a specific one.
- `--image <path>`: REQUIRED — output file path. Use a descriptive name (e.g.,
./out/hero-landscape.png).
Step 3: Run
bun scripts/main.ts \
--prompt "..." \
--image ./out.png \
--provider openai \
--ar 1:1 \
--quality hdOn success the CLI prints the resolved absolute path and byte count. In --json mode it emits:
{ "success": true, "provider": "openai", "model": "gpt-image-1", "image": "/abs/path.png", "size_bytes": 1416341, "format": "png" }Echo the path back to the user.
Step 4: Handle errors
- `config: No provider selected ...` — no API key in env and no EXTEND.md. Loop back to Step 0.
- `[openai] OpenAI images API 401 ...` — key invalid or expired. Ask the user to refresh it.
- `[openai] ... 400 ... content_policy_violation` — prompt blocked. Show the raw error to the user; do not paraphrase.
- Timeouts / network errors — retry once. If still failing, surface the raw message and
providerso the user knows what to check.
See references/error_codes.md for a per-provider error table.
References
Read on demand:
- `references/providers.md` — all 8 providers, required env vars, default models, strengths.
- `references/aspect_ratio_map.md` — how each provider interprets
--ar. - `references/error_codes.md` — common errors per provider and fixes.
- `references/config/first-time-setup.md` — step-by-step for
--setup. - `references/config/extend-schema.md` — EXTEND.md schema reference.
Template for EXTEND.md: assets/EXTEND.template.md.
happy-image-gen — EXTEND.md template
Copy this file to one of:
./.happy-skills/happy-image-gen/EXTEND.md(project-level)~/.happy-skills/happy-image-gen/EXTEND.md(user-level)
Fill in only the sections you need. Delete providers you don't use — the CLI only complains when you ask for a provider with no credentials resolvable.
version: 1
# Defaults — used when the CLI flag is not passed
default_provider: openai
default_quality: hd # draft | hd | ultra
default_aspect_ratio: "1:1" # 1:1 | 16:9 | 9:16 | 3:4 | 4:3
default_save_dir: ~/Pictures/claude-gen
# Per-provider default model (overrides hard-coded defaults)
default_model:
openai: gpt-image-1
# google: gemini-3-pro-image-preview
# replicate: black-forest-labs/flux-1.1-pro
# stability: stable-image-ultra
# fal: fal-ai/flux-pro
# ark: doubao-seedream-4.5-250228
# bailian: qwen-image-2.0-pro
# siliconflow: Kwai-Kolors/Kolors
# Credentials. NEVER paste raw keys here — always use api_key_env or api_key_source.
providers:
openai:
api_key_env: OPENAI_API_KEY
# base_url: https://api.openai.com/v1 # uncomment for Azure / proxy
# google:
# api_key_env: GOOGLE_API_KEY
# replicate:
# api_key_env: REPLICATE_API_TOKEN
# stability:
# api_key_env: STABILITY_API_KEY
# fal:
# api_key_env: FAL_KEY
# ark:
# api_key_env: ARK_API_KEY
# bailian:
# api_key_env: DASHSCOPE_API_KEY
# base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
# siliconflow:
# api_key_env: SILICONFLOW_API_KEYUsing 1Password (recommended for teams)
If you have the 1password skill installed from the same marketplace, reference keys without exporting them:
providers:
openai:
api_key_source: "op://Personal/OpenAI/credential"
ark:
api_key_source: "op://Team/Volc Engine Ark/key"The CLI will shell out to op read at runtime.
Security notes
- Never commit raw API keys in this file.
- Project-level
EXTEND.mdis meant to be committed; user-level is meant to stay out of repos. - If you commit a project-level
EXTEND.md, make sure everyproviders.*block usesapi_key_envorapi_key_source— not a raw value.
{
"name": "happy-image-gen",
"version": "0.1.0",
"description": "Universal AI image generation skill — OpenAI, Google, Replicate, Stability, FAL, Ark, Bailian, SiliconFlow",
"type": "module",
"private": true,
"engines": {
"bun": ">=1.1.0"
},
"scripts": {
"gen": "bun scripts/main.ts"
}
}
Aspect ratio — per-provider mapping
The CLI exposes a unified --ar W:H flag. Each provider maps this to its own size / ratio parameter. This table documents the mapping so you can predict what the API actually receives.
---
OpenAI
--ar | gpt-image-1 size | dall-e-3 size | dall-e-2 size |
|---|---|---|---|
1:1 | 1024x1024 | 1024x1024 | 1024x1024 |
16:9 | 1536x1024 | 1792x1024 | 1024x1024 (forced) |
9:16 | 1024x1536 | 1024x1792 | 1024x1024 (forced) |
3:4 | 1024x1536 | 1024x1792 | 1024x1024 (forced) |
4:3 | 1536x1024 | 1792x1024 | 1024x1024 (forced) |
| other | 1024x1024 | 1024x1024 | 1024x1024 |
Quality mapping (--quality → OpenAI quality field):
draft→low(gpt-image) /standard(dall-e-3)hd→medium(gpt-image) /standard(dall-e-3)ultra→high(gpt-image) /hd(dall-e-3)
DALL·E 2 does not expose a quality parameter.
---
Google (planned)
Gemini Image accepts an aspectRatio parameter directly with values 1:1 / 16:9 / 9:16 / 3:4 / 4:3. Pass through unchanged.
Imagen accepts the same. Both support all five ratios natively.
---
Replicate (planned)
Model-dependent. The CLI will inspect the model's schema via Replicate's model API:
black-forest-labs/flux-*— acceptsaspect_ratio:1:1 / 16:9 / 9:16 / 21:9 / 9:21 / 4:5 / 5:4.stability-ai/sdxl— acceptswidth/height(powers of 8). The CLI picks a canonical size per ratio.
---
Stability AI (planned)
/v2beta/stable-image/generate/ultra accepts aspect_ratio: 21:9 / 16:9 / 3:2 / 5:4 / 1:1 / 4:5 / 2:3 / 9:16 / 9:21. The CLI passes user-provided ratio through if supported; falls back to the nearest.
---
FAL (planned)
Per-model. Most FAL Flux endpoints use image_size as a string enum: square_hd / square / landscape_16_9 / landscape_4_3 / portrait_9_16 / portrait_4_3. The CLI translates:
--ar | FAL image_size |
|---|---|
1:1 | square_hd |
16:9 | landscape_16_9 |
4:3 | landscape_4_3 |
9:16 | portrait_9_16 |
3:4 | portrait_4_3 |
---
Ark — Seedream (planned)
Seedream accepts size: 2048x2048 / 2048x1152 / 1152x2048 / 1728x1296 / 1296x1728. The CLI picks per ratio; no arbitrary W×H.
---
Bailian — qwen-image (planned)
Accepts size: 1024x1024 / 1664x928 / 928x1664 / 1472x1140 / 1140x1472. Mapping is analogous to Seedream.
---
SiliconFlow (planned)
Per-model; Kolors accepts image_size: 1024x1024 / 960x1280 / 768x1024 / 720x1440 / 720x1280. Flux-dev on SiliconFlow accepts arbitrary width / height.
---
Unsupported or exotic ratios
If the user asks for 21:9 or 9:21 on a provider that does not support it, the CLI:
1. Falls back to the nearest supported ratio (16:9 or 9:16). 2. Prints a warning line: warning: provider X mapped 21:9 → 16:9. 3. Does NOT abort the job.
For strict ratio requirements, pass --size <WxH> explicitly — this overrides --ar on providers that accept arbitrary dimensions.
EXTEND.md schema
The CLI looks for EXTEND.md in this priority order:
1. ./.happy-skills/happy-image-gen/EXTEND.md 2. $XDG_CONFIG_HOME/happy-skills/happy-image-gen/EXTEND.md 3. ~/.happy-skills/happy-image-gen/EXTEND.md
The file body is YAML. Optional Markdown commentary (e.g., --- frontmatter or prose above/below) is ignored.
---
Top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | integer | 1 | Schema version. |
default_provider | provider id | (none) | Used when --provider is not passed. If unset, auto-detect from env vars. |
default_quality | draft / hd / ultra | hd | Used when --quality is not passed. |
default_aspect_ratio | string (e.g. "1:1") | 1:1 | Used when --ar and --size are not passed. |
default_save_dir | path | (cwd) | Used to resolve relative --image paths. ~ expands to home. |
default_model | object | {} | Per-provider default model id. See below. |
providers | object | {} | Per-provider credentials + endpoint overrides. See below. |
---
default_model map
default_model:
openai: gpt-image-1
google: gemini-3-pro-image-preview
replicate: black-forest-labs/flux-1.1-pro
stability: stable-image-ultra
fal: fal-ai/flux-pro
ark: doubao-seedream-4.5-250228
bailian: qwen-image-2.0-pro
siliconflow: Kwai-Kolors/KolorsAll keys are optional — unspecified providers fall back to the module's hard-coded default.
---
providers map
Each provider supports these fields:
| Field | Type | Description |
|---|---|---|
api_key_env | string | Name of env var holding the API key. Preferred for simplicity. |
api_key_source | string | Alternative: op://Vault/Item/field for 1Password (requires the 1password skill). |
base_url | string | Override API endpoint (e.g., Azure OpenAI, regional Aliyun). |
Example:
providers:
openai:
api_key_env: OPENAI_API_KEY
bailian:
api_key_env: DASHSCOPE_API_KEY
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
openai_azure:
api_key_source: "op://Team/Azure OpenAI/credential"
base_url: https://my-resource.openai.azure.com/openai/deployments/my-deployAt least one of api_key_env or api_key_source must resolve, otherwise generation fails with config: No provider selected ....
---
Never store raw keys in EXTEND.md
Do not put raw API keys in this file. Always use api_key_env (pointing at a shell var) or api_key_source (1Password reference). The file is likely to be committed to version control and end up in backups — raw keys leak.
If you see a key stored in plaintext, warn the user and suggest rotating it immediately.
---
Example: minimal
default_provider: openai
default_quality: hd
providers:
openai:
api_key_env: OPENAI_API_KEYExample: global + China coverage
version: 1
default_provider: openai
default_quality: hd
default_aspect_ratio: "1:1"
default_save_dir: ~/Pictures/claude-gen
default_model:
openai: gpt-image-1
ark: doubao-seedream-4.5-250228
bailian: qwen-image-2.0-pro
providers:
openai:
api_key_env: OPENAI_API_KEY
ark:
api_key_env: ARK_API_KEY
bailian:
api_key_env: DASHSCOPE_API_KEYhappy-image-gen — first-time setup
When the user has never configured this skill, walk them through these steps. Do not generate any image until setup is complete.
1. Confirm Bun is installed
command -v bunIf missing, point to https://bun.sh and suggest:
curl -fsSL https://bun.sh/install | bashThe CLI can also run under npx -y bun without an install.
2. Pick a provider
Ask which provider(s) the user wants to enable. Most users want 1–3 providers. Typical combinations:
- Global tier:
openai+google(covers most English prompts). - China tier:
ark(Seedream) +bailian(qwen-image) (best Chinese text fidelity). - Broad experimentation:
replicate(access to any open-weight model) +fal(fast drafts).
See ../providers.md for per-provider strengths.
3. Issue API keys
For each chosen provider, guide the user to generate a key and export it to the shell. Suggested env var names match what the CLI reads by default:
| Provider | Env var | Where to generate |
|---|---|---|
| openai | OPENAI_API_KEY | https://platform.openai.com/api-keys |
GOOGLE_API_KEY or GEMINI_API_KEY | https://aistudio.google.com/app/apikey | |
| replicate | REPLICATE_API_TOKEN | https://replicate.com/account/api-tokens |
| stability | STABILITY_API_KEY | https://platform.stability.ai/account/keys |
| fal | FAL_KEY | https://fal.ai/dashboard/keys |
| ark | ARK_API_KEY | https://console.volcengine.com/ark |
| bailian | DASHSCOPE_API_KEY | https://dashscope.console.aliyun.com/apiKey |
| siliconflow | SILICONFLOW_API_KEY | https://cloud.siliconflow.cn/account/ak |
Two ways to store a key:
(A) Shell env var — simplest:
export OPENAI_API_KEY="sk-..."Add to ~/.zshrc / ~/.bashrc for persistence.
(B) 1Password reference — recommended for teams. Install the 1password skill from the same marketplace, then in EXTEND.md:
providers:
openai:
api_key_source: "op://Personal/OpenAI/credential"4. Create EXTEND.md
The CLI reads config in this order (first match wins):
1. ./.happy-skills/happy-image-gen/EXTEND.md (project-level — commit to repo if shared) 2. $XDG_CONFIG_HOME/happy-skills/happy-image-gen/EXTEND.md 3. ~/.happy-skills/happy-image-gen/EXTEND.md (user-level — default)
Copy assets/EXTEND.template.md to one of those paths and fill in the sections relevant to the user's chosen providers. See ./extend-schema.md for the full schema.
Minimum viable config:
default_provider: openai
default_quality: hd
default_aspect_ratio: "1:1"
default_save_dir: ~/Pictures/claude-gen
default_model:
openai: gpt-image-1
providers:
openai:
api_key_env: OPENAI_API_KEY5. Smoke test
bun scripts/main.ts --prompt "a calico cat on green grass" --image /tmp/test.pngExpected output: ✓ Generated PNG (~1–2 MB) via openai/gpt-image-1 followed by the resolved path.
If this fails:
- Check the error prefix —
config: ...means setup issue,[openai] ...means the provider itself. - Re-read
../error_codes.mdfor common fixes.
happy-image-gen — error table & fixes
Common errors by provider. The CLI prefixes all provider errors with [<provider>] so you can match rows below by prefix.
---
Config errors (CLI-side, no [<provider>] prefix)
| Message | Cause | Fix |
|---|---|---|
config: No prompt provided | Neither --prompt nor --promptfiles given | Pass one. |
config: --image is required | Output path missing | Pass --image ./out.png. |
config: No provider selected ... | No explicit --provider and no env key detected | Run --setup or export a provider's API key. |
config: Unknown provider: X | --provider X is typo | Use one of: openai google replicate stability fal ark bailian siliconflow. |
config: Provider 'X' is declared but not yet implemented | You asked for a provider still on the roadmap | Fall back to openai for now, or wait for the next release. |
---
OpenAI ([openai] ...)
| Status / pattern | Cause | Fix |
|---|---|---|
401 | OPENAI_API_KEY invalid or revoked | Re-issue a key on platform.openai.com, export it. |
403 | Org doesn't have access to the requested model | Try dall-e-3 instead of gpt-image-1, or enable the model in the org settings. |
400 invalid_request_error ... size | Ratio→size translation produced an invalid size for the model | Pass --size 1024x1024 explicitly, or drop to dall-e-3. |
400 content_policy_violation | Prompt blocked by OpenAI safety layer | Surface the raw error to the user. Do not rephrase silently. |
429 rate_limit_exceeded | Too many requests | Retry with exponential backoff, or lower concurrency. |
Reference images with OpenAI ... require GPT Image models | Used --ref with DALL·E | Switch to --model gpt-image-1. |
Failed to download image from OpenAI URL | URL returned by OpenAI expired before we fetched it | Retry the generation. |
---
Google (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
INVALID_ARGUMENT | Prompt policy or unsupported field | Read the message field for specifics. |
PERMISSION_DENIED | API not enabled in the GCP project | Enable Vertex AI / Generative Language API. |
UNAVAILABLE | Transient backend issue | Retry. |
RESOURCE_EXHAUSTED | Quota | Request quota increase or switch key. |
---
Replicate (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
401 | REPLICATE_API_TOKEN invalid | Refresh token. |
422 | Input schema violation (e.g., prompt too long) | Shorten prompt or provide required schema fields. |
model not found | Wrong model slug | Use owner/name[:version]. Check replicate.com/<model>. |
Prediction stuck in starting | Cold model boot | Retry after 15–30s, or warm with a draft call first. |
---
Stability AI (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
403 unauthorized | API key invalid | Refresh. |
content_moderation | Prompt or output blocked | Surface raw error; do not rephrase. |
invalid_parameters | Unsupported aspect_ratio | Pass --size or pick a supported ratio. |
---
FAL (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
401 unauthorized | FAL_KEY invalid | Refresh. |
Queue in_progress for >60s | Slow model | Increase client timeout. |
422 | Schema mismatch | Check model's FAL docs for expected fields. |
---
Ark — Seedream (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
InvalidApiKey.InvalidAuthentication | ARK_API_KEY invalid | Refresh from ark.cn-beijing.volces.com. |
Unsupported size | Size not in Seedream's fixed list | Pick one of the documented sizes. |
SensitiveContentDetected | Safety filter | Surface raw error. |
---
Bailian — qwen-image (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
InvalidApiKey | DASHSCOPE_API_KEY invalid | Refresh in Aliyun console. |
DataInspectionFailed | Content filter | Surface raw error. |
ModelNotFound | Wrong model id | Use qwen-image-2.0-pro or latest. |
---
SiliconFlow (planned)
| Status / pattern | Cause | Fix |
|---|---|---|
401 | SILICONFLOW_API_KEY invalid | Refresh. |
422 invalid image_size | Unsupported size/ratio | Use per-model supported values. |
---
Generic network issues
If a provider call fails with ECONNREFUSED, ETIMEDOUT, EAI_AGAIN:
1. Check basic connectivity (curl -I https://api.openai.com). 2. If using a corporate network / VPN, check that the provider's host is not blocked. 3. Set OPENAI_BASE_URL (or equivalent) to a compatible proxy if the direct API is blocked.
If the user is in mainland China and hitting global APIs (api.openai.com, Google), recommend using ark / bailian / siliconflow providers instead — they are domestic and fast.
happy-image-gen — provider reference
Each provider has distinct strengths, auth mechanics, and default models. Pick based on the user's explicit ask first; otherwise follow the auto-detection priority in SKILL.md.
---
1. OpenAI — implemented ✅
Env vars:
OPENAI_API_KEY(required)OPENAI_BASE_URL(optional, defaulthttps://api.openai.com/v1) — useful for Azure OpenAI or local compatible gateways.OPENAI_IMAGE_MODEL(optional, defaultgpt-image-1) — override the default model.
Default model: gpt-image-1
Other supported models: gpt-image-1, dall-e-3, dall-e-2, and any gpt-image-* you have access to.
Supported aspect ratios: 1:1, 16:9, 9:16 (via size mapping). Other ratios fall back to square.
Reference images: Only for gpt-image-* (not DALL·E 2/3). Uses the /images/edits endpoint with multipart form data.
Strengths: most reliable text-in-image rendering among global providers; great coherence and prompt adherence.
---
2. Google (Gemini Image / Imagen) — planned 🚧
Env vars:
GOOGLE_API_KEYorGEMINI_API_KEY(required)GOOGLE_IMAGE_MODEL(optional)
Default model: gemini-3-pro-image-preview (multimodal) or imagen-4.0-generate-001.
Strengths: strong photorealism, reference image support via multimodal, good at compositional prompts.
---
3. Replicate — planned 🚧
Env vars:
REPLICATE_API_TOKEN(required)REPLICATE_IMAGE_MODEL(optional, e.g.,black-forest-labs/flux-1.1-pro)
Default model: user-supplied. Good defaults: black-forest-labs/flux-1.1-pro, bytedance/sdxl-lightning-4step, stability-ai/sdxl.
Strengths: broadest model zoo; pay-per-second pricing; good for experimental workflows.
---
4. Stability AI — planned 🚧
Env vars:
STABILITY_API_KEY(required)
Default model: stable-image-ultra or stable-image-core depending on quality preset.
Strengths: first-party SD models, structured output controls.
---
5. FAL — planned 🚧
Env vars:
FAL_KEY(required)
Default model: fal-ai/nano-banana or fal-ai/flux-pro depending on quality preset.
Strengths: extremely fast inference via queue API, great developer ergonomics.
---
6. Ark (火山引擎方舟 — Seedream) — planned 🚧
Env vars:
ARK_API_KEY(required)
Default model: doubao-seedream-4.5-250228 (or latest Seedream snapshot).
Strengths: best-in-class Chinese text rendering; tuned for Chinese prompts; excellent poster / ad composition.
---
7. Bailian (阿里百炼 — qwen-image / wanx) — planned 🚧
Env vars:
DASHSCOPE_API_KEY(required)
Default model: qwen-image-2.0-pro or wanx-v1.
Strengths: Chinese prompt understanding, integrated with Aliyun ecosystem, long-text poster layouts.
---
8. SiliconFlow — planned 🚧
Env vars:
SILICONFLOW_API_KEY(required)
Default model: Kwai-Kolors/Kolors or black-forest-labs/FLUX.1-dev.
Strengths: hosts Kolors (Chinese-strong), Flux dev, cheap inference pricing.
---
Provider selection cheatsheet
| User need | Recommended provider |
|---|---|
| Text inside image (English) | openai (gpt-image-1) |
| Text inside image (Chinese) | ark (Seedream 4.5) or bailian (qwen-image) |
| Photorealistic portraits | google (Imagen) or replicate (Flux Pro) |
| Lightning-fast draft | fal or siliconflow |
| Free / open-weight feel | replicate (SDXL) or siliconflow (Kolors) |
| Reference-driven edit (multiple refs) | google (multimodal) or openai (gpt-image edits) |
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { CliArgs, Provider, ProviderModule, Quality } from "./types";
import { ProviderError, ConfigError } from "./types";
import { writeBytesToFile, validateImageMagic } from "./utils/output";
const KNOWN_PROVIDERS: Provider[] = [
"openai",
"google",
"replicate",
"stability",
"fal",
"ark",
"bailian",
"siliconflow",
];
const IMPLEMENTED: Set<Provider> = new Set([
"openai",
"google",
"replicate",
"stability",
"fal",
"ark",
"bailian",
"siliconflow",
]);
function printUsage(): void {
console.log(`happy-image-gen — universal AI image generation CLI
Usage:
bun scripts/main.ts --prompt "A cat on grass" --image out.png
bun scripts/main.ts --promptfiles system.md user.md --image out.png
bun scripts/main.ts --prompt "..." --ref src.png --image out.png
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from files (concatenated with blank lines)
--image <path> Output image path (REQUIRED)
--ref <path> Reference image (repeat to pass multiple)
--provider <id> openai | google | replicate | stability | fal | ark | bailian | siliconflow
-m, --model <id> Provider-specific model id
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
--size <WxH> Explicit size (e.g., 1024x1024), overrides --ar
--quality <preset> draft | hd (default) | ultra
--n <count> Number of variations (default 1, not all providers support)
--json Emit JSON result to stdout instead of plain text
--setup Print first-time setup guide and exit
-h, --help Show this message
Environment variables (single-provider MVP uses env for API keys):
OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_IMAGE_MODEL
(more providers coming — see references/providers.md)
See references/config/first-time-setup.md and assets/EXTEND.template.md for the EXTEND.md config mechanism.
`);
}
async function printSetupGuide(): Promise<void> {
const here = path.dirname(fileURLToPath(import.meta.url));
const guidePath = path.resolve(here, "../references/config/first-time-setup.md");
try {
const guide = await readFile(guidePath, "utf8");
console.log(guide);
} catch {
console.log(
"No first-time-setup.md found. Start with:\n export OPENAI_API_KEY=sk-...\n bun scripts/main.ts --prompt 'A cat' --image cat.png"
);
}
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
referenceImages: [],
n: 1,
json: false,
setup: false,
help: false,
};
const tokens = [...argv];
while (tokens.length > 0) {
const token = tokens.shift()!;
switch (token) {
case "-p":
case "--prompt":
args.prompt = tokens.shift() ?? null;
break;
case "--promptfiles":
while (tokens.length > 0 && !tokens[0]!.startsWith("--") && tokens[0] !== "-p") {
args.promptFiles.push(tokens.shift()!);
}
break;
case "--image":
args.imagePath = tokens.shift() ?? null;
break;
case "--ref":
args.referenceImages.push(tokens.shift() ?? "");
break;
case "--provider":
args.provider = tokens.shift() as Provider | null;
break;
case "-m":
case "--model":
args.model = tokens.shift() ?? null;
break;
case "--ar":
case "--aspect":
args.aspectRatio = tokens.shift() ?? null;
break;
case "--size":
args.size = tokens.shift() ?? null;
break;
case "--quality":
args.quality = tokens.shift() as Quality | null;
break;
case "--n":
args.n = Math.max(1, Number(tokens.shift() || 1));
break;
case "--json":
args.json = true;
break;
case "--setup":
args.setup = true;
break;
case "-h":
case "--help":
args.help = true;
break;
default:
if (!token.startsWith("-") && args.prompt === null && args.promptFiles.length === 0) {
args.prompt = token;
} else {
console.error(`Unknown argument: ${token}`);
args.help = true;
}
}
}
return args;
}
async function resolvePrompt(args: CliArgs): Promise<string> {
if (args.prompt && args.promptFiles.length === 0) return args.prompt;
if (args.promptFiles.length > 0) {
const parts: string[] = [];
if (args.prompt) parts.push(args.prompt);
for (const file of args.promptFiles) {
parts.push(await readFile(file, "utf8"));
}
return parts.join("\n\n").trim();
}
throw new ConfigError("No prompt provided. Use --prompt or --promptfiles.");
}
function detectProvider(explicit: Provider | null): Provider {
if (explicit) {
if (!KNOWN_PROVIDERS.includes(explicit)) {
throw new ConfigError(
`Unknown provider: ${explicit}. Known: ${KNOWN_PROVIDERS.join(", ")}`
);
}
return explicit;
}
if (process.env.OPENAI_API_KEY) return "openai";
if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) return "google";
if (process.env.REPLICATE_API_TOKEN) return "replicate";
if (process.env.STABILITY_API_KEY) return "stability";
if (process.env.FAL_KEY) return "fal";
if (process.env.ARK_API_KEY) return "ark";
if (process.env.DASHSCOPE_API_KEY) return "bailian";
if (process.env.SILICONFLOW_API_KEY) return "siliconflow";
throw new ConfigError(
"No provider selected and no provider-specific API key found in env. Pass --provider or set OPENAI_API_KEY / GOOGLE_API_KEY / FAL_KEY / ARK_API_KEY / DASHSCOPE_API_KEY / SILICONFLOW_API_KEY / STABILITY_API_KEY / REPLICATE_API_TOKEN."
);
}
async function loadProvider(provider: Provider): Promise<ProviderModule> {
if (!IMPLEMENTED.has(provider)) {
throw new ConfigError(
`Provider '${provider}' is declared but not yet implemented in this release. Available: ${[
...IMPLEMENTED,
].join(", ")}.`
);
}
const mod = await import(`./providers/${provider}.ts`);
const module = mod as ProviderModule;
if (typeof module.defaultModel !== "function" || typeof module.generate !== "function") {
throw new ConfigError(`Provider module '${provider}' is missing defaultModel/generate exports.`);
}
return module;
}
async function run(argv: string[]): Promise<number> {
const args = parseArgs(argv);
if (args.help) {
printUsage();
return 0;
}
if (args.setup) {
await printSetupGuide();
return 0;
}
if (!args.imagePath) {
console.error("--image is required (output path).");
printUsage();
return 2;
}
const prompt = await resolvePrompt(args);
const provider = detectProvider(args.provider);
const mod = await loadProvider(provider);
const model = args.model ?? mod.defaultModel();
if (!args.quality) args.quality = "hd";
const bytes = await mod.generate(prompt, model, args);
await writeBytesToFile(bytes, args.imagePath);
const kind = validateImageMagic(bytes);
if (args.json) {
console.log(
JSON.stringify(
{
success: true,
provider,
model,
image: path.resolve(args.imagePath),
size_bytes: bytes.byteLength,
format: kind,
},
null,
2
)
);
} else {
console.log(
`✓ Generated ${kind.toUpperCase()} (${bytes.byteLength} bytes) via ${provider}/${model}`
);
console.log(` ${path.resolve(args.imagePath)}`);
}
return 0;
}
run(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
if (err instanceof ProviderError) {
console.error(`[${err.provider}] ${err.message}`);
} else if (err instanceof ConfigError) {
console.error(`config: ${err.message}`);
} else if (err instanceof Error) {
console.error(err.stack || err.message);
} else {
console.error(String(err));
}
process.exit(1);
});
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { orientationOf } from "../utils/aspect_ratio";
const API_BASE =
process.env.ARK_BASE_URL ||
"https://ark.cn-beijing.volces.com/api/v3";
// Seedream only accepts a fixed set of sizes.
const SEEDREAM_SIZES = {
square: "2048x2048",
landscape16x9: "2048x1152",
portrait16x9: "1152x2048",
landscape4x3: "1728x1296",
portrait4x3: "1296x1728",
};
export function defaultModel(): string {
return process.env.ARK_IMAGE_MODEL || "doubao-seedream-4-0-250528";
}
function requireApiKey(): string {
const key = process.env.ARK_API_KEY;
if (!key) {
throw new ProviderError(
"ARK_API_KEY not set. Get one at https://console.volcengine.com/ark",
"ark"
);
}
return key;
}
function pickSize(args: CliArgs): string {
if (args.size) return args.size;
const ar = args.aspectRatio;
if (!ar || ar === "1:1") return SEEDREAM_SIZES.square;
if (ar === "4:3") return SEEDREAM_SIZES.landscape4x3;
if (ar === "3:4") return SEEDREAM_SIZES.portrait4x3;
const orientation = orientationOf(ar);
if (orientation === "landscape") return SEEDREAM_SIZES.landscape16x9;
if (orientation === "portrait") return SEEDREAM_SIZES.portrait16x9;
return SEEDREAM_SIZES.square;
}
type ArkResponse = {
data?: Array<{ url?: string; b64_json?: string }>;
error?: { message?: string; code?: string };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
if (args.referenceImages.length > 0) {
// Seedream supports i2i via a separate endpoint / parameter. Not covered in MVP.
throw new ProviderError(
"Ark Seedream reference-image workflow (SeeEdit) is not wired in this release. Use --provider openai or google with --ref, or drop --ref.",
"ark"
);
}
const body: Record<string, unknown> = {
model,
prompt,
size: pickSize(args),
response_format: "url",
watermark: false,
};
const res = await fetch(`${API_BASE}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Ark ${res.status}: ${text}`, "ark");
}
const payload = (await res.json()) as ArkResponse;
const item = payload.data?.[0];
if (item?.b64_json) {
return Uint8Array.from(Buffer.from(item.b64_json, "base64"));
}
if (item?.url) {
const imgRes = await fetch(item.url);
if (!imgRes.ok) {
throw new ProviderError(`Failed to download Ark output (${imgRes.status})`, "ark");
}
return new Uint8Array(await imgRes.arrayBuffer());
}
throw new ProviderError("Ark response contained no image data", "ark");
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { orientationOf } from "../utils/aspect_ratio";
const DEFAULT_BASE =
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
const NATIVE_BASE = `${DEFAULT_BASE}/api/v1`;
// qwen-image supports these fixed sizes; other models accept "Wx H" with an asterisk.
const QWEN_IMAGE_SIZES = {
square: "1024*1024",
landscape16x9: "1664*928",
portrait16x9: "928*1664",
landscape4x3: "1472*1140",
portrait4x3: "1140*1472",
};
export function defaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || "wanx2.1-t2i-turbo";
}
function requireApiKey(): string {
const key = process.env.DASHSCOPE_API_KEY;
if (!key) {
throw new ProviderError(
"DASHSCOPE_API_KEY not set. Get one at https://dashscope.console.aliyun.com/apiKey",
"bailian"
);
}
return key;
}
function pickSize(args: CliArgs): string {
if (args.size) return args.size.replace(/x/i, "*");
const ar = args.aspectRatio;
if (!ar || ar === "1:1") return QWEN_IMAGE_SIZES.square;
if (ar === "4:3") return QWEN_IMAGE_SIZES.landscape4x3;
if (ar === "3:4") return QWEN_IMAGE_SIZES.portrait4x3;
const orientation = orientationOf(ar);
if (orientation === "landscape") return QWEN_IMAGE_SIZES.landscape16x9;
if (orientation === "portrait") return QWEN_IMAGE_SIZES.portrait16x9;
return QWEN_IMAGE_SIZES.square;
}
type SubmitResponse = {
output?: { task_id?: string; task_status?: string };
code?: string;
message?: string;
};
type QueryResponse = {
output?: {
task_id?: string;
task_status?: string;
results?: Array<{ url?: string }>;
code?: string;
message?: string;
};
code?: string;
message?: string;
};
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
if (args.referenceImages.length > 0) {
throw new ProviderError(
"Bailian reference-image workflow not wired in MVP. Drop --ref to use text-to-image.",
"bailian"
);
}
const submitBody = {
model,
input: { prompt },
parameters: {
size: pickSize(args),
n: Math.max(1, args.n),
},
};
const submitRes = await fetch(`${NATIVE_BASE}/services/aigc/text2image/image-synthesis`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-DashScope-Async": "enable",
},
body: JSON.stringify(submitBody),
});
if (!submitRes.ok) {
const text = await submitRes.text();
throw new ProviderError(`Bailian submit ${submitRes.status}: ${text}`, "bailian");
}
const submit = (await submitRes.json()) as SubmitResponse;
const taskId = submit.output?.task_id;
if (!taskId) {
throw new ProviderError(
`Bailian submit returned no task_id: ${submit.message ?? JSON.stringify(submit)}`,
"bailian"
);
}
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
await sleep(2000);
const queryRes = await fetch(`${NATIVE_BASE}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!queryRes.ok) {
const text = await queryRes.text();
throw new ProviderError(`Bailian query ${queryRes.status}: ${text}`, "bailian");
}
const payload = (await queryRes.json()) as QueryResponse;
const status = payload.output?.task_status;
if (status === "SUCCEEDED") {
const url = payload.output?.results?.[0]?.url;
if (!url) throw new ProviderError("Bailian succeeded but no url in results", "bailian");
const imgRes = await fetch(url);
if (!imgRes.ok) {
throw new ProviderError(`Failed to download Bailian output (${imgRes.status})`, "bailian");
}
return new Uint8Array(await imgRes.arrayBuffer());
}
if (status === "FAILED" || status === "CANCELED" || status === "UNKNOWN") {
throw new ProviderError(
`Bailian task ${status}: ${payload.output?.message ?? "(no detail)"}`,
"bailian"
);
}
}
throw new ProviderError("Bailian task timed out after 180s", "bailian");
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { orientationOf } from "../utils/aspect_ratio";
import { getMimeType } from "../utils/output";
const SYNC_BASE = "https://fal.run";
export function defaultModel(): string {
return process.env.FAL_IMAGE_MODEL || "fal-ai/nano-banana";
}
function requireApiKey(): string {
const key = process.env.FAL_KEY;
if (!key) {
throw new ProviderError(
"FAL_KEY not set. Get one at https://fal.ai/dashboard/keys",
"fal"
);
}
return key;
}
type FalImageItem = { url?: string; content_type?: string };
type FalResponse = { images?: FalImageItem[]; image?: FalImageItem; error?: string };
function pickImageSize(args: CliArgs): string {
if (args.size) return args.size;
const orientation = orientationOf(args.aspectRatio);
if (args.aspectRatio === "4:3") return "landscape_4_3";
if (args.aspectRatio === "3:4") return "portrait_4_3";
if (orientation === "landscape") return "landscape_16_9";
if (orientation === "portrait") return "portrait_9_16";
return "square_hd";
}
async function referenceAsDataUri(refPath: string): Promise<string> {
const bytes = await readFile(refPath);
const mime = getMimeType(path.basename(refPath));
return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const input: Record<string, unknown> = {
prompt,
image_size: pickImageSize(args),
num_images: Math.max(1, args.n),
};
if (args.referenceImages.length > 0) {
input.image_url = await referenceAsDataUri(args.referenceImages[0]!);
}
const url = `${SYNC_BASE}/${model.replace(/^\/+/, "")}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Key ${apiKey}`,
},
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`FAL ${res.status}: ${text}`, "fal");
}
const payload = (await res.json()) as FalResponse;
const item = payload.images?.[0] ?? payload.image;
const outputUrl = item?.url;
if (!outputUrl) {
throw new ProviderError("FAL response contained no image URL", "fal");
}
const imgRes = await fetch(outputUrl);
if (!imgRes.ok) {
throw new ProviderError(`Failed to download FAL output (${imgRes.status})`, "fal");
}
return new Uint8Array(await imgRes.arrayBuffer());
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { getMimeType } from "../utils/output";
const API_BASE = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_IMAGE_RATIOS = new Set(["1:1", "16:9", "9:16", "3:4", "4:3"]);
export function defaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
}
function requireApiKey(): string {
const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
if (!key) {
throw new ProviderError(
"GOOGLE_API_KEY (or GEMINI_API_KEY) not set. Get one at https://aistudio.google.com/app/apikey",
"google"
);
}
return key;
}
function pickAspect(ar: string | null): string {
if (!ar) return "1:1";
if (GEMINI_IMAGE_RATIOS.has(ar)) return ar;
return "1:1";
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
if (model.startsWith("imagen")) {
return generateWithImagen(apiKey, prompt, model, args);
}
return generateWithGemini(apiKey, prompt, model, args);
}
type GeminiInlineData = { mime_type?: string; mimeType?: string; data: string };
type GeminiPart = {
text?: string;
inline_data?: GeminiInlineData;
inlineData?: GeminiInlineData;
};
type GeminiResponse = {
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
promptFeedback?: { blockReason?: string };
error?: { message?: string; code?: number };
};
async function generateWithGemini(
apiKey: string,
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const parts: GeminiPart[] = [{ text: prompt }];
for (const refPath of args.referenceImages) {
const bytes = await readFile(refPath);
parts.push({
inline_data: {
mime_type: getMimeType(path.basename(refPath)),
data: Buffer.from(bytes).toString("base64"),
},
});
}
const body = {
contents: [{ role: "user", parts }],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig: { aspectRatio: pickAspect(args.aspectRatio) },
},
};
const url = `${API_BASE}/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Gemini generateContent ${res.status}: ${text}`, "google");
}
const payload = (await res.json()) as GeminiResponse;
if (payload.promptFeedback?.blockReason) {
throw new ProviderError(
`Gemini blocked the prompt (${payload.promptFeedback.blockReason})`,
"google"
);
}
const candidateParts = payload.candidates?.[0]?.content?.parts ?? [];
for (const part of candidateParts) {
const inline = part.inline_data ?? part.inlineData;
if (inline?.data) {
return Uint8Array.from(Buffer.from(inline.data, "base64"));
}
}
throw new ProviderError("Gemini response contained no image part", "google");
}
type ImagenPrediction = { bytesBase64Encoded?: string; mimeType?: string };
type ImagenResponse = {
predictions?: ImagenPrediction[];
error?: { message?: string };
};
async function generateWithImagen(
apiKey: string,
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
if (args.referenceImages.length > 0) {
throw new ProviderError(
"Imagen endpoint does not accept reference images. Use gemini-3-pro-image-preview for multimodal.",
"google"
);
}
const body = {
instances: [{ prompt }],
parameters: {
sampleCount: 1,
aspectRatio: pickAspect(args.aspectRatio),
},
};
const url = `${API_BASE}/models/${model}:predict?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Imagen predict ${res.status}: ${text}`, "google");
}
const payload = (await res.json()) as ImagenResponse;
const b64 = payload.predictions?.[0]?.bytesBase64Encoded;
if (!b64) {
throw new ProviderError("Imagen response contained no prediction bytes", "google");
}
return Uint8Array.from(Buffer.from(b64, "base64"));
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { orientationOf } from "../utils/aspect_ratio";
import { getMimeType } from "../utils/output";
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
export function defaultModel(): string {
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1";
}
function pickSize(model: string, args: CliArgs): string {
if (args.size) return args.size;
const orientation = orientationOf(args.aspectRatio);
if (model.includes("dall-e-3")) {
if (orientation === "landscape") return "1792x1024";
if (orientation === "portrait") return "1024x1792";
return "1024x1024";
}
if (model.includes("dall-e-2")) return "1024x1024";
if (orientation === "landscape") return "1536x1024";
if (orientation === "portrait") return "1024x1536";
return "1024x1024";
}
function pickQuality(model: string, quality: CliArgs["quality"]): string | null {
if (model.includes("dall-e-3")) {
return quality === "ultra" ? "hd" : "standard";
}
if (model.includes("gpt-image")) {
if (quality === "draft") return "low";
if (quality === "ultra") return "high";
return "medium";
}
return null;
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const baseUrl = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new ProviderError(
"OPENAI_API_KEY is not set. Export it in your shell or add to EXTEND.md providers.openai.api_key_env.",
"openai"
);
}
const size = pickSize(model, args);
const quality = pickQuality(model, args.quality);
if (args.referenceImages.length > 0) {
if (model.includes("dall-e-3") || model.includes("dall-e-2")) {
throw new ProviderError(
"Reference images require a gpt-image-* model. Pass --model gpt-image-1 or set OPENAI_IMAGE_MODEL.",
"openai"
);
}
return generateWithEdits(baseUrl, apiKey, prompt, model, size, quality, args.referenceImages);
}
return generateFromText(baseUrl, apiKey, prompt, model, size, quality);
}
async function generateFromText(
baseUrl: string,
apiKey: string,
prompt: string,
model: string,
size: string,
quality: string | null
): Promise<Uint8Array> {
const body: Record<string, unknown> = { model, prompt, size, n: 1 };
if (quality) body.quality = quality;
const res = await fetch(`${baseUrl}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`OpenAI images API ${res.status}: ${text}`, "openai");
}
const payload = (await res.json()) as OpenAIImageResponse;
return extractImage(payload);
}
async function generateWithEdits(
baseUrl: string,
apiKey: string,
prompt: string,
model: string,
size: string,
quality: string | null,
references: string[]
): Promise<Uint8Array> {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
form.append("size", size);
if (quality) form.append("quality", quality);
for (const refPath of references) {
const bytes = await readFile(refPath);
const name = path.basename(refPath);
form.append("image[]", new Blob([bytes], { type: getMimeType(name) }), name);
}
const res = await fetch(`${baseUrl}/images/edits`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`OpenAI edits API ${res.status}: ${text}`, "openai");
}
const payload = (await res.json()) as OpenAIImageResponse;
return extractImage(payload);
}
async function extractImage(result: OpenAIImageResponse): Promise<Uint8Array> {
const item = result.data?.[0];
if (item?.b64_json) {
return Uint8Array.from(Buffer.from(item.b64_json, "base64"));
}
if (item?.url) {
const res = await fetch(item.url);
if (!res.ok) {
throw new ProviderError(`Failed to download image from OpenAI URL (${res.status})`, "openai");
}
return new Uint8Array(await res.arrayBuffer());
}
throw new ProviderError("OpenAI response contained no image data", "openai");
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { getMimeType } from "../utils/output";
const API_BASE = "https://api.replicate.com/v1";
export function defaultModel(): string {
return process.env.REPLICATE_IMAGE_MODEL || "black-forest-labs/flux-1.1-pro";
}
function requireApiKey(): string {
const key = process.env.REPLICATE_API_TOKEN;
if (!key) {
throw new ProviderError(
"REPLICATE_API_TOKEN not set. Get one at https://replicate.com/account/api-tokens",
"replicate"
);
}
return key;
}
async function referenceAsDataUri(refPath: string): Promise<string> {
const bytes = await readFile(refPath);
const mime = getMimeType(path.basename(refPath));
return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
}
type Prediction = {
id?: string;
status?: "starting" | "processing" | "succeeded" | "failed" | "canceled";
output?: string | string[] | null;
error?: string | null;
urls?: { get?: string };
};
function buildInput(prompt: string, args: CliArgs, refDataUris: string[]): Record<string, unknown> {
const input: Record<string, unknown> = { prompt };
if (args.aspectRatio) input.aspect_ratio = args.aspectRatio;
if (args.size) {
const match = args.size.match(/^(\d+)x(\d+)$/i);
if (match) {
input.width = Number(match[1]);
input.height = Number(match[2]);
}
}
input.num_outputs = Math.max(1, args.n);
if (refDataUris.length > 0) {
// Most Replicate image models accept a single `image` input; some accept `image_reference` / `image_prompt`.
// We pass both common names to maximize compatibility.
input.image = refDataUris[0];
input.image_prompt = refDataUris[0];
}
return input;
}
function parsePredictionOutput(pred: Prediction): string {
const output = pred.output;
if (Array.isArray(output) && output.length > 0 && typeof output[0] === "string") {
return output[0];
}
if (typeof output === "string" && output.length > 0) {
return output;
}
throw new ProviderError("Replicate prediction returned no output URL", "replicate");
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollPrediction(apiKey: string, pred: Prediction, deadlineMs: number): Promise<Prediction> {
const getUrl = pred.urls?.get;
if (!getUrl) return pred;
let current = pred;
const start = Date.now();
while (current.status !== "succeeded" && current.status !== "failed" && current.status !== "canceled") {
if (Date.now() - start > deadlineMs) break;
await sleep(1500);
const r = await fetch(getUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
if (!r.ok) {
const text = await r.text();
throw new ProviderError(`Replicate poll ${r.status}: ${text}`, "replicate");
}
current = (await r.json()) as Prediction;
}
return current;
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const refs: string[] = [];
for (const refPath of args.referenceImages) {
refs.push(await referenceAsDataUri(refPath));
}
const [owner, name] = model.split("/");
if (!owner || !name) {
throw new ProviderError(
`Replicate model id must be 'owner/name' (got '${model}'). Example: black-forest-labs/flux-1.1-pro`,
"replicate"
);
}
const body = { input: buildInput(prompt, args, refs) };
const createUrl = `${API_BASE}/models/${owner}/${name}/predictions`;
const res = await fetch(createUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
Prefer: "wait=60",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Replicate create ${res.status}: ${text}`, "replicate");
}
let pred = (await res.json()) as Prediction;
if (pred.status !== "succeeded" && pred.status !== "failed" && pred.status !== "canceled") {
pred = await pollPrediction(apiKey, pred, 180_000);
}
if (pred.status === "failed" || pred.status === "canceled") {
throw new ProviderError(`Replicate prediction ${pred.status}: ${pred.error ?? "(no detail)"}`, "replicate");
}
const outputUrl = parsePredictionOutput(pred);
const imgRes = await fetch(outputUrl);
if (!imgRes.ok) {
throw new ProviderError(`Failed to download Replicate output (${imgRes.status})`, "replicate");
}
return new Uint8Array(await imgRes.arrayBuffer());
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { orientationOf } from "../utils/aspect_ratio";
const API_BASE =
process.env.SILICONFLOW_BASE_URL || "https://api.siliconflow.cn/v1";
// Kolors-family defaults. Flux-family on SiliconFlow accepts arbitrary W x H.
const KOLORS_SIZES = {
square: "1024x1024",
landscape4x3: "1024x768",
portrait4x3: "768x1024",
landscape16x9: "1280x720",
portrait16x9: "720x1280",
};
export function defaultModel(): string {
return process.env.SILICONFLOW_IMAGE_MODEL || "Kwai-Kolors/Kolors";
}
function requireApiKey(): string {
const key = process.env.SILICONFLOW_API_KEY;
if (!key) {
throw new ProviderError(
"SILICONFLOW_API_KEY not set. Get one at https://cloud.siliconflow.cn/account/ak",
"siliconflow"
);
}
return key;
}
function pickImageSize(args: CliArgs, model: string): string {
if (args.size) return args.size;
const ar = args.aspectRatio;
const isFlux = /flux/i.test(model);
if (!ar || ar === "1:1") return isFlux ? "1024x1024" : KOLORS_SIZES.square;
if (ar === "4:3") return KOLORS_SIZES.landscape4x3;
if (ar === "3:4") return KOLORS_SIZES.portrait4x3;
const orientation = orientationOf(ar);
if (orientation === "landscape") return KOLORS_SIZES.landscape16x9;
if (orientation === "portrait") return KOLORS_SIZES.portrait16x9;
return KOLORS_SIZES.square;
}
type SiliconFlowResponse = {
images?: Array<{ url?: string }>;
data?: Array<{ url?: string; b64_json?: string }>;
error?: { message?: string };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
if (args.referenceImages.length > 0) {
throw new ProviderError(
"SiliconFlow reference-image workflow not wired in MVP. Drop --ref to use text-to-image.",
"siliconflow"
);
}
const body: Record<string, unknown> = {
model,
prompt,
image_size: pickImageSize(args, model),
batch_size: Math.max(1, args.n),
num_inference_steps: 20,
guidance_scale: 7.5,
};
const res = await fetch(`${API_BASE}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`SiliconFlow ${res.status}: ${text}`, "siliconflow");
}
const payload = (await res.json()) as SiliconFlowResponse;
const item = payload.images?.[0] ?? payload.data?.[0];
if (item?.b64_json) {
return Uint8Array.from(Buffer.from(item.b64_json, "base64"));
}
if (item?.url) {
const imgRes = await fetch(item.url);
if (!imgRes.ok) {
throw new ProviderError(`Failed to download SiliconFlow output (${imgRes.status})`, "siliconflow");
}
return new Uint8Array(await imgRes.arrayBuffer());
}
throw new ProviderError("SiliconFlow response contained no image", "siliconflow");
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
const API_BASE = "https://api.stability.ai/v2beta/stable-image";
const STABILITY_RATIOS = new Set(["21:9", "16:9", "3:2", "5:4", "1:1", "4:5", "2:3", "9:16", "9:21"]);
export function defaultModel(): string {
return process.env.STABILITY_IMAGE_MODEL || "ultra";
}
function requireApiKey(): string {
const key = process.env.STABILITY_API_KEY;
if (!key) {
throw new ProviderError(
"STABILITY_API_KEY not set. Get one at https://platform.stability.ai/account/keys",
"stability"
);
}
return key;
}
function pickEndpoint(model: string): string {
const normalized = model.trim().toLowerCase();
const known: Record<string, string> = {
ultra: `${API_BASE}/generate/ultra`,
core: `${API_BASE}/generate/core`,
"sd3": `${API_BASE}/generate/sd3`,
"stable-image-ultra": `${API_BASE}/generate/ultra`,
"stable-image-core": `${API_BASE}/generate/core`,
"stable-diffusion-3": `${API_BASE}/generate/sd3`,
};
return known[normalized] ?? `${API_BASE}/generate/${normalized}`;
}
function pickAspect(ar: string | null): string {
if (!ar) return "1:1";
if (STABILITY_RATIOS.has(ar)) return ar;
return "1:1";
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
if (args.referenceImages.length > 0) {
throw new ProviderError(
"Stability provider in this release only supports text-to-image. Reference image workflows require /control/sketch or /control/structure endpoints.",
"stability"
);
}
const form = new FormData();
form.append("prompt", prompt);
form.append("aspect_ratio", pickAspect(args.aspectRatio));
form.append("output_format", "png");
const res = await fetch(pickEndpoint(model), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "image/*",
},
body: form,
});
if (!res.ok) {
const text = await res.text();
throw new ProviderError(`Stability ${res.status}: ${text}`, "stability");
}
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
}
export type Provider =
| "openai"
| "google"
| "replicate"
| "stability"
| "fal"
| "ark"
| "bailian"
| "siliconflow";
export type Quality = "draft" | "hd" | "ultra";
export type CliArgs = {
prompt: string | null;
promptFiles: string[];
imagePath: string | null;
provider: Provider | null;
model: string | null;
aspectRatio: string | null;
size: string | null;
quality: Quality | null;
referenceImages: string[];
n: number;
json: boolean;
setup: boolean;
help: boolean;
};
export type ExtendConfig = {
version?: number;
default_provider?: Provider;
default_quality?: Quality;
default_aspect_ratio?: string;
default_save_dir?: string;
default_model?: Partial<Record<Provider, string>>;
providers?: Partial<
Record<
Provider,
{
api_key_env?: string;
api_key_source?: string;
base_url?: string;
}
>
>;
};
export type ProviderModule = {
defaultModel: () => string;
generate: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
};
export class ConfigError extends Error {
code = "CONFIG_ERROR";
}
export class ProviderError extends Error {
constructor(message: string, public provider: Provider) {
super(message);
}
code = "PROVIDER_ERROR";
}
export type Orientation = "square" | "landscape" | "portrait";
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (!(w > 0) || !(h > 0)) return null;
return { width: w, height: h };
}
export function orientationOf(ar: string | null): Orientation {
if (!ar) return "square";
const parsed = parseAspectRatio(ar);
if (!parsed) return "square";
const ratio = parsed.width / parsed.height;
if (ratio > 1.15) return "landscape";
if (ratio < 0.87) return "portrait";
return "square";
}
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
export async function writeBytesToFile(bytes: Uint8Array, outputPath: string): Promise<void> {
const dir = path.dirname(outputPath);
await mkdir(dir, { recursive: true });
await writeFile(outputPath, bytes);
}
export function getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "image/png";
}
export function validateImageMagic(bytes: Uint8Array): "png" | "jpeg" | "webp" | "gif" | "unknown" {
if (bytes.length < 12) return "unknown";
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "png";
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "jpeg";
if (
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
) {
return "webp";
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "gif";
return "unknown";
}