
Tuzi Image Gen
- 199 installs
- 37 repo stars
- Updated April 24, 2026
- tuziapi/tuzi-skills
Use tuzi-image-gen for development tasks
About
tuzi-image-gen: A skill skill for development. This skill provides functionality for development workflows.
- tuzi-image-gen
Tuzi Image Gen by the numbers
- 199 all-time installs (skills.sh)
- Ranked #2,040 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tuziapi/tuzi-skills --skill tuzi-image-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 199 |
|---|---|
| repo stars | ★ 37 |
| Last updated | April 24, 2026 |
| Repository | tuziapi/tuzi-skills ↗ |
What it does
Use tuzi-image-gen for development tasks
Files
Image Generation (AI SDK)
Multi-provider image generation. Default provider: Tuzi (兔子API, api.tu-zi.com).
Script Directory
Agent Execution: 1. SKILL_DIR = this SKILL.md file's directory 2. Script path = ${SKILL_DIR}/scripts/main.ts
Step 0: Load Preferences ⛔ BLOCKING
CRITICAL: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
0.1 Check API Key
Check if the selected provider's API key is available. For Tuzi (default):
# Check env, then .tuzi-skills/.env files
echo "${TUZI_API_KEY:-not_set}"
grep -s TUZI_API_KEY .tuzi-skills/.env "$HOME/.tuzi-skills/.env"| Result | Action |
|---|---|
| Key found | Continue to Step 0.2 |
| Key NOT found | ⛔ Run API key setup (see references/config/first-time-setup.md → "API Key Setup") → Store key → Then continue |
CRITICAL: If API key is missing, MUST guide user to obtain and store it BEFORE any generation. Generation is BLOCKED until key is configured.
0.2 Check EXTEND.md
Check EXTEND.md existence (priority: project → user):
test -f .tuzi-skills/tuzi-image-gen/EXTEND.md && echo "project"
test -f "$HOME/.tuzi-skills/tuzi-image-gen/EXTEND.md" && echo "user"| Result | Action |
|---|---|
| Found | Load, parse, apply settings. If default_model.[provider] is null → ask model only (Flow 2) |
| Not found | ⛔ Run first-time setup (references/config/first-time-setup.md) → Save EXTEND.md → Then continue |
CRITICAL: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
| Path | Location |
|---|---|
.tuzi-skills/tuzi-image-gen/EXTEND.md | Project directory |
$HOME/.tuzi-skills/tuzi-image-gen/EXTEND.md | User home |
EXTEND.md Supports: Default provider | Default quality | Default aspect ratio | Default image size | OpenAI image API dialect | Default models
Schema: references/config/preferences-schema.md
Usage
# Basic (uses Tuzi provider by default)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
# With quality (Tuzi: 1k/2k/4k)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
# 4K VIP model
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --model gemini-3-pro-image-preview-4k-vip
# With reference images
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make it blue" --image out.png --ref source.png
# From prompt files
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
# Async model (auto-polls)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --model gemini-3-pro-image-preview-2k-async
# Other providers
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider google
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# OpenAI-compatible gateway dialect
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --imageApiDialect ratio-metadataOptions
| Option | Description |
|---|---|
--prompt <text>, -p | Prompt text |
--promptfiles <files...> | Read prompt from files (concatenated) |
--image <path> | Output image path (required) |
| `--provider tuzi\ | google\ |
--model <id>, -m | Model ID (see Tuzi Models section for full list) |
--ar <ratio> | Aspect ratio (e.g., 16:9, 1:1, 4:3). Tuzi converts to NxN format |
--size <WxH> | Size override (e.g., 1024x1024, 16x9) |
| `--quality normal\ | 2k` |
| `--imageSize 1K\ | 2K\ |
| `--imageApiDialect openai-native\ | ratio-metadata` |
--ref <files...> | Reference images. Tuzi: base64 in JSON body. Google: multimodal. OpenAI: edits API |
--n <count> | Number of images |
--json | JSON output |
Tuzi Models
Tuzi API (api.tu-zi.com) is the default provider. Models differ in quality, speed, and supported parameters.
Recommended
| Model ID | Alias | Quality | Notes |
|---|---|---|---|
gemini-3-pro-image-preview | nano-banana-pro | --quality 1k/2k/4k | Default. High quality, supports quality param |
gemini-3.1-flash-image-preview | nano-banana-2 | --quality 1k/2k/4k | Fast, supports extended aspect ratios |
gemini-3-pro-image-preview-vip | nano-banana-pro-vip | 1k built-in | High quality, VIP |
gemini-3-pro-image-preview-2k-vip | nano-banana-pro-2k-vip | 2k built-in | High quality 2K, VIP |
gemini-3-pro-image-preview-4k-vip | nano-banana-pro-4k-vip | 4k built-in | High quality 4K, VIP |
gemini-2.5-flash-image-vip | nano-banana-vip | 1k built-in | Fastest, VIP |
More Models
| Model ID | Alias | Notes |
|---|---|---|
gemini-3-pro-image-preview | nano-banana-pro | --quality 1k/2k/4k |
gemini-2.5-flash-image | nano-banana | Fast |
gemini-3-pro-image-preview-hd | nano-banana-pro-hd | HD built-in |
gemini-3-pro-image-preview-2k | nano-banana-pro-2k | 2K built-in |
gemini-3-pro-image-preview-4k | nano-banana-pro-4k | 4K built-in |
gpt-image-2 | — | OpenAI latest image model, supports modern size rules |
gpt-image-1.5 | — | Size: 1:1, 3:2, 2:3 only |
bfl-flux-2-pro | flux-2-pro | Flux |
bfl-flux-2-max | flux-2-max | Flux highest quality |
flux-kontext-pro | kontext-pro | Multi-ref editing |
flux-kontext-max | kontext-max | Multi-ref editing (max) |
doubao-seedream-4-0-250828 | Seedream 4.0 | 2K/4K |
doubao-seedream-4-5-251128 | Seedream 4.5 | 2K/4K |
doubao-seedream-5-0-260128 | Seedream 5.0 lite | 2K/3K |
Async Models
Auto-detected. Script submits task and polls until complete (5s interval, max 30min).
| Model ID | Notes |
|---|---|
gemini-3-pro-image-preview-async | 1K async |
gemini-3-pro-image-preview-2k-async | 2K async |
gemini-3-pro-image-preview-4k-async | 4K async |
mj-imagine | Midjourney, MJ params in prompt |
Model-Specific Parameters
Quality (--quality or --imageSize 1K|2K|4K):
| Applies to | Values | Notes |
|---|---|---|
gemini-3.1-flash-image-preview | 1k / 2k / 4k | Default model, quality adjustable |
gemini-3-pro-image-preview | 1k / 2k / 4k | Quality adjustable |
*-2k-vip, *-4k-vip, *-hd | — | Quality built into model name, param ignored |
| Other models | — | Param ignored |
Aspect ratio (--ar):
| Applies to | Supported ratios |
|---|---|
| Gemini models (default) | 1:1, 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 21:9 |
gemini-3.1-flash-image-preview | Above + 1:4, 4:1, 1:8, 8:1 (extreme ratios) |
gpt-image-2 | Flexible ratio via generated valid pixel size; custom --size must satisfy OpenAI constraints |
gpt-image-1.5 | 1:1, 3:2, 2:3 |
doubao-seedream-* | Use explicit --size / --imageSize; --ar alone is not supported |
| Omitted | Model auto-decides |
Reference images (--ref):
gpt-image-*: OpenAI-style/images/editsmultipart upload- Other sync models: base64 data URL in JSON
imagefield - Async models:
input_referencein FormData - Most Tuzi sync models support reference images;
doubao-seedream-3-0-*does not
Seedream (doubao-seedream-*):
| Applies to | Size rules | Reference images |
|---|---|---|
doubao-seedream-5-0-* | 2K, 3K, or explicit WxH | Up to 14 |
doubao-seedream-4-5-* | 2K, 4K, or explicit WxH | Up to 14 |
doubao-seedream-4-0-* | 1K, 2K, 4K, or explicit WxH | Up to 14 |
doubao-seedream-3-0-* | Explicit WxH only | Not supported |
Environment Variables
| Variable | Description |
|---|---|
TUZI_API_KEY | Tuzi API key (https://api.tu-zi.com) |
TUZI_IMAGE_MODEL | Tuzi default model (default: gemini-3-pro-image-preview) |
TUZI_BASE_URL | Custom Tuzi endpoint (default: https://api.tu-zi.com/v1) |
GOOGLE_API_KEY | Google API key |
OPENAI_API_KEY | OpenAI API key |
DASHSCOPE_API_KEY | DashScope API key (阿里云) |
REPLICATE_API_TOKEN | Replicate API token |
GOOGLE_IMAGE_MODEL | Google model override |
OPENAI_IMAGE_MODEL | OpenAI model override (default: gpt-image-2) |
DASHSCOPE_IMAGE_MODEL | DashScope model override |
REPLICATE_IMAGE_MODEL | Replicate model override |
GOOGLE_BASE_URL | Custom Google endpoint |
OPENAI_BASE_URL | Custom OpenAI endpoint |
OPENAI_IMAGE_API_DIALECT | OpenAI-compatible endpoint dialect (openai-native or ratio-metadata) |
DASHSCOPE_BASE_URL | Custom DashScope endpoint |
REPLICATE_BASE_URL | Custom Replicate endpoint |
Load Priority: CLI args > EXTEND.md > env vars > <cwd>/.tuzi-skills/.env > ~/.tuzi-skills/.env
Model Resolution
Priority (highest → lowest), all providers:
1. CLI: --model <id> 2. EXTEND.md: default_model.[provider] 3. Env var: <PROVIDER>_IMAGE_MODEL 4. Built-in default
Agent MUST display model info before each generation:
- Show:
Using [provider] / [model] - Show switch hint:
Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL
Provider Selection
1. --provider specified → use it 2. --ref provided + no --provider → Tuzi > Google > OpenAI > Replicate 3. Only one API key available → use that provider 4. Multiple available → Tuzi first
OpenAI-Compatible Dialect
When provider=openai, the endpoint may still be an OpenAI-compatible gateway rather than native OpenAI infrastructure.
openai-native: standard OpenAI Images API request bodyratio-metadata: sends aspect-ratiosizeplusmetadata.resolution, useful for compatibility gateways in front of non-native image backends
Current limitation: ratio-metadata is text-to-image only. When using --ref, stay on openai-native.
Quality Presets
| Preset | Tuzi | OpenAI | |
|---|---|---|---|
normal | 1k | 1K | 1024px |
2k (default) | 2k | 2K | 2048px |
--imageSize 1K|2K|4K overrides quality for Tuzi and Google.
Generation Mode
Default: Sequential (one at a time).
Parallel: Only when user explicitly requests. Use Task tool with run_in_background=true, recommended 4 subagents (max 8).
Error Handling
- Missing API key → ⛔ MUST run API key setup from Step 0.1 (guide user to https://api.tu-zi.com/token, store in .tuzi-skills/.env). Do NOT suggest GOOGLE_API_KEY or other provider keys unless user explicitly chose a different provider.
- Generation failure → auto-retry once
- Tuzi
PROHIBITED_CONTENT→ content rejection error - Tuzi
NO_IMAGE→ prompt too vague, suggest more explicit prompt - Async timeout → error after 30 minutes
- Invalid aspect ratio → warning, proceed with default
Replicate Models
Format: owner/name or owner/name:version
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana-2 --ar 16:9
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A fashion photo" --image out.png --provider replicate --model bytedance/seedream-4.5 --size 4K
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cinematic scene" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 4KSupported Families
| Family | Example models | Reference images | Aspect ratio | Size handling | Notes |
|---|---|---|---|---|---|
| Nano Banana | google/nano-banana, google/nano-banana-pro, google/nano-banana-2 | Up to 14 | Standard documented ratios | --quality maps to 1K/2K, --size can infer ratio + 1K/2K | Best default compatibility |
| Seedream 4.5 | bytedance/seedream-4.5 | Up to 14 | Standard documented ratios | --size supports 2K, 4K, or custom WxH | Good for high-resolution output |
| Seedream 5 Lite | bytedance/seedream-5-lite | Up to 14 | Standard documented ratios | --size supports 2K or 3K | Simpler size presets |
| Wan 2.7 Image | wan-video/wan-2.7-image | Up to 9 | W:H ratios converted into size | --size supports 1K, 2K, or custom WxH | Reference images use images field |
| Wan 2.7 Image Pro | wan-video/wan-2.7-image-pro | Up to 9 | W:H ratios converted into size | --size supports 1K, 2K, 4K, or custom WxH | 4K only for text-to-image (no --ref) |
Replicate Notes
- Current tool saves exactly one image per request, so Replicate supports
--n 1only. --imageSizeis not used for Replicate. Use--quality,--ar, or--sizeinstead.- Unknown Replicate models still work for prompt-only generation, but
--ref,--ar, and--sizeare only guaranteed for the families listed above. - Documented aspect ratios shared by Nano Banana and Seedream in this tool:
1:1,2:3,3:2,3:4,4:3,4:5,5:4,9:16,16:9,21:9.
Extension Support
Custom configurations via EXTEND.md. See Step 0 for paths and supported options.
First-Time Setup
Overview
Triggered when: 1. API key missing for selected provider → API key setup 2. No EXTEND.md found → full setup (provider + model + preferences) 3. EXTEND.md found but default_model.[provider] is null → model selection only
API Key Setup
Triggered when: Provider's API key is not found in env, .tuzi-skills/.env, or ~/.tuzi-skills/.env.
Language: Use user's input language or saved language preference.
Step 1: Guide user to obtain API key
For Tuzi (default provider), display:
TUZI_API_KEY 未配置。请先获取 API Key:
1. 打开 https://api.tu-zi.com/token 创建并获取 API Key
2. 视频教程:https://www.bilibili.com/video/BV1k4PqzPEKz/For other providers, display the corresponding key setup URL:
- Google:
GOOGLE_API_KEY— https://aistudio.google.com/apikey - OpenAI:
OPENAI_API_KEY— https://platform.openai.com/api-keys - DashScope:
DASHSCOPE_API_KEY— https://dashscope.console.aliyun.com/apiKey - Replicate:
REPLICATE_API_TOKEN— https://replicate.com/account/api-tokens
Step 2: Ask user for API key
IMPORTANT: Do NOT use AskUserQuestion for this step. Instead, directly ask the user in plain text to paste their API key. Example:
请粘贴你的 Tuzi API Key(以 sk- 开头):Wait for user to reply with the key string. Validate it starts with sk-.
Step 3: Ask save location (if no .env exists yet)
header: "Save Location"
question: "API Key 保存位置?"
options:
- label: "Project (Recommended)"
description: ".tuzi-skills/.env (仅当前项目)"
- label: "User"
description: "~/.tuzi-skills/.env (所有项目共享)"Step 4: Store API key
1. Create directory if needed: mkdir -p <chosen-path>/.tuzi-skills 2. Append key to .env file (do NOT overwrite existing content):
echo "TUZI_API_KEY=<user-provided-key>" >> <chosen-path>/.tuzi-skills/.env3. Confirm to user: "API Key 已保存到 <full-path>/.tuzi-skills/.env" 4. Set the key in current process env so generation can proceed immediately
Provider-specific env var names
| Provider | Env Variable | Obtain URL |
|---|---|---|
| Tuzi | TUZI_API_KEY | https://api.tu-zi.com/token |
GOOGLE_API_KEY | https://aistudio.google.com/apikey | |
| OpenAI | OPENAI_API_KEY | https://platform.openai.com/api-keys |
| DashScope | DASHSCOPE_API_KEY | https://dashscope.console.aliyun.com/apiKey |
| Replicate | REPLICATE_API_TOKEN | https://replicate.com/account/api-tokens |
Setup Flow
No EXTEND.md found EXTEND.md found, model null
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ AskUserQuestion │ │ AskUserQuestion │
│ (full setup) │ │ (model only) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Create EXTEND.md │ │ Update EXTEND.md │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
Continue ContinueFlow 1: No EXTEND.md (Full Setup)
Language: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
Question 1: Default Provider
header: "Provider"
question: "Default image generation provider?"
options:
- label: "Tuzi (Recommended)"
description: "兔子API - nano-banana models via api.tu-zi.com"
- label: "Google"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image - consistent quality, reliable output"
- label: "DashScope"
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
- label: "Replicate"
description: "Community models - nano-banana, Seedream, Wan"Question 2: Default Google Model
Only show if user selected Google or auto-detect (no explicit provider).
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"Question 3: Default Quality
header: "Quality"
question: "Default image quality?"
options:
- label: "2k (Recommended)"
description: "2048px - covers, illustrations, infographics"
- label: "normal"
description: "1024px - quick previews, drafts"Question 4: Save Location
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project (Recommended)"
description: ".tuzi-skills/ (this project only)"
- label: "User"
description: "~/.tuzi-skills/ (all projects)"Save Locations
| Choice | Path | Scope |
|---|---|---|
| Project | .tuzi-skills/tuzi-image-gen/EXTEND.md | Current project |
| User | $HOME/.tuzi-skills/tuzi-image-gen/EXTEND.md | All projects |
EXTEND.md Template
---
version: 1
default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_image_api_dialect: null
default_model:
google: [selected google model or null]
openai: null
dashscope: null
replicate: null
tuzi: null
---Flow 2: EXTEND.md Exists, Model Null
When EXTEND.md exists but default_model.[current_provider] is null, ask ONLY the model question for the current provider.
Google Model Selection
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"OpenAI Model Selection
header: "OpenAI Model"
question: "Choose a default OpenAI image generation model?"
options:
- label: "gpt-image-2 (Recommended)"
description: "Latest GPT Image model, supports newer size rules and better gateway compatibility"
- label: "gpt-image-1.5"
description: "Previous GPT Image generation model"DashScope Model Selection
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "z-image-turbo (Recommended)"
description: "Fast generation, good quality"
- label: "z-image-ultra"
description: "Higher quality, slower generation"Replicate Model Selection
header: "Replicate Model"
question: "Choose a default Replicate image generation model?"
options:
- label: "google/nano-banana-2 (Recommended)"
description: "Newest Nano Banana family model on Replicate, strong general default"
- label: "google/nano-banana-pro"
description: "Stable Nano Banana Pro default, compatible with quality + aspect-ratio flow"
- label: "google/nano-banana"
description: "Google's base Nano Banana model on Replicate"
- label: "bytedance/seedream-4.5"
description: "High-resolution model, supports 2K/4K and custom WxH"
- label: "bytedance/seedream-5-lite"
description: "Seedream Lite model, supports 2K/3K"
- label: "wan-video/wan-2.7-image-pro"
description: "Wan image model with optional 4K text-to-image output"Tuzi Model Selection
header: "Tuzi Model"
question: "Choose a default Tuzi image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "nano-banana-pro - high quality, supports quality param (1k/2k/4k)"
- label: "gemini-3.1-flash-image-preview"
description: "nano-banana-2 - fast, supports quality param (1k/2k/4k), extended aspect ratios"
- label: "gemini-3-pro-image-preview-vip"
description: "nano-banana-pro-vip - high quality, VIP"
- label: "gemini-3-pro-image-preview-2k-vip"
description: "nano-banana-pro-2k-vip - 2K built-in, VIP"
- label: "gemini-3-pro-image-preview-4k-vip"
description: "nano-banana-pro-4k-vip - 4K built-in, VIP"
- label: "gemini-3-pro-image-preview"
description: "nano-banana-pro - supports quality param (1k/2k/4k)"
- label: "gpt-image-2"
description: "GPT Image model with newer size rules"Update EXTEND.md
After user selects a model:
1. Read existing EXTEND.md 2. If default_model: section exists → update the provider-specific key 3. If default_model: section missing → add the full section:
default_model:
google: [value or null]
openai: [value or null]
dashscope: [value or null]
replicate: [value or null]
tuzi: [value or null]Only set the selected provider's model; leave others as their current value or null.
After Setup
1. Create directory if needed 2. Write/update EXTEND.md with frontmatter 3. Confirm: "Preferences saved to [path]" 4. Continue with image generation
Preferences Schema
Full Schema
---
version: 1
default_provider: null # google|openai|dashscope|replicate|tuzi|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k)
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
default_image_api_dialect: null # openai-native|ratio-metadata|null
default_model:
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
openai: null # e.g., "gpt-image-2"
dashscope: null # e.g., "z-image-turbo"
replicate: null # e.g., "google/nano-banana-2"
tuzi: null # e.g., "gemini-3.1-flash-image-preview"
---Field Reference
| Field | Type | Default | Description |
|---|---|---|---|
version | int | 1 | Schema version |
default_provider | string\ | null | null |
default_quality | string\ | null | null |
default_aspect_ratio | string\ | null | null |
default_image_size | string\ | null | null |
default_image_api_dialect | string\ | null | null |
default_model.google | string\ | null | null |
default_model.openai | string\ | null | null |
default_model.dashscope | string\ | null | null |
default_model.replicate | string\ | null | null |
default_model.tuzi | string\ | null | null |
Examples
Minimal:
---
version: 1
default_provider: google
default_quality: 2k
---Full:
---
version: 1
default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_image_api_dialect: null
default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-2"
dashscope: "z-image-turbo"
replicate: "google/nano-banana-2"
tuzi: "gemini-3-pro-image-preview"
---import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import type { CliArgs, Provider, ExtendConfig, OpenAIImageApiDialect } from "./types";
function printUsage(): void {
console.log(`用法:
npx -y bun scripts/main.ts --prompt "一只猫" --image cat.png
npx -y bun scripts/main.ts --prompt "风景画" --image landscape.png --ar 16:9
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
选项:
-p, --prompt <text> 提示词文本
--promptfiles <files...> 从文件读取提示词(多文件拼接)
--image <path> 输出图片路径(必填)
--provider tuzi|google|openai|dashscope|replicate 指定服务商(默认自动检测)
-m, --model <id> 模型 ID
--ar <ratio> 宽高比(如 16:9、1:1、4:3)
--size <WxH> 尺寸(如 1024x1024)
--quality normal|2k 质量预设(默认: 2k)
--imageSize 1K|2K|4K 图片尺寸(默认: 由 quality 决定)
--imageApiDialect <id> OpenAI 兼容网关方言(openai-native|ratio-metadata)
--ref <files...> 参考图片
--n <count> 生成数量(默认: 1)
--json JSON 输出
-h, --help 显示帮助
环境变量:
TUZI_API_KEY Tuzi API 密钥(https://api.tu-zi.com)
TUZI_IMAGE_MODEL Tuzi 默认模型(gemini-3-pro-image-preview)
TUZI_BASE_URL 自定义 Tuzi 端点
OPENAI_API_KEY OpenAI API 密钥
GOOGLE_API_KEY Google API 密钥
GEMINI_API_KEY Gemini API 密钥(GOOGLE_API_KEY 别名)
DASHSCOPE_API_KEY DashScope API 密钥(阿里云通义万象)
REPLICATE_API_TOKEN Replicate API 令牌
OPENAI_IMAGE_MODEL OpenAI 默认模型(gpt-image-2)
GOOGLE_IMAGE_MODEL Google 默认模型(gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL DashScope 默认模型(z-image-turbo)
REPLICATE_IMAGE_MODEL Replicate 默认模型(google/nano-banana-pro)
OPENAI_BASE_URL 自定义 OpenAI 端点
OPENAI_IMAGE_API_DIALECT OpenAI 兼容网关方言(openai-native|ratio-metadata)
OPENAI_IMAGE_USE_CHAT 使用 /chat/completions 替代 /images/generations(true|false)
GOOGLE_BASE_URL 自定义 Google 端点
DASHSCOPE_BASE_URL 自定义 DashScope 端点
REPLICATE_BASE_URL 自定义 Replicate 端点
加载优先级: 命令行参数 > EXTEND.md > 环境变量 > <cwd>/.tuzi-skills/.env > ~/.tuzi-skills/.env`);
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
json: false,
help: false,
};
const positional: string[] = [];
const takeMany = (i: number): { items: string[]; next: number } => {
const items: string[] = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j]!;
if (v.startsWith("-")) break;
items.push(v);
j++;
}
return { items, next: j - 1 };
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--help" || a === "-h") {
out.help = true;
continue;
}
if (a === "--json") {
out.json = true;
continue;
}
if (a === "--prompt" || a === "-p") {
const v = argv[++i];
if (!v) throw new Error(`缺少 ${a} 的值`);
out.prompt = v;
continue;
}
if (a === "--promptfiles") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error("--promptfiles 缺少文件参数");
out.promptFiles.push(...items);
i = next;
continue;
}
if (a === "--image") {
const v = argv[++i];
if (!v) throw new Error("缺少 --image 的值");
out.imagePath = v;
continue;
}
if (a === "--provider") {
const v = argv[++i];
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate" && v !== "tuzi") throw new Error(`无效的服务商: ${v}`);
out.provider = v;
continue;
}
if (a === "--model" || a === "-m") {
const v = argv[++i];
if (!v) throw new Error(`缺少 ${a} 的值`);
out.model = v;
continue;
}
if (a === "--ar") {
const v = argv[++i];
if (!v) throw new Error("缺少 --ar 的值");
out.aspectRatio = v;
continue;
}
if (a === "--size") {
const v = argv[++i];
if (!v) throw new Error("缺少 --size 的值");
out.size = v;
continue;
}
if (a === "--quality") {
const v = argv[++i];
if (v !== "normal" && v !== "2k") throw new Error(`无效的质量参数: ${v}`);
out.quality = v;
continue;
}
if (a === "--imageSize") {
const v = argv[++i]?.toUpperCase();
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`无效的图片尺寸: ${v}`);
out.imageSize = v;
continue;
}
if (a === "--imageApiDialect") {
const v = argv[++i];
if (v !== "openai-native" && v !== "ratio-metadata") {
throw new Error(`无效的 OpenAI 图片方言: ${v}`);
}
out.imageApiDialect = v;
continue;
}
if (a === "--ref" || a === "--reference") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error(`缺少 ${a} 的文件参数`);
out.referenceImages.push(...items);
i = next;
continue;
}
if (a === "--n") {
const v = argv[++i];
if (!v) throw new Error("缺少 --n 的值");
out.n = parseInt(v, 10);
if (isNaN(out.n) || out.n < 1) throw new Error(`无效的数量: ${v}`);
continue;
}
if (a.startsWith("-")) {
throw new Error(`未知选项: ${a}`);
}
positional.push(a);
}
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
out.prompt = positional.join(" ");
}
return out;
}
async function loadEnvFile(p: string): Promise<Record<string, string>> {
try {
const content = await readFile(p, "utf8");
const env: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let val = trimmed.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
env[key] = val;
}
return env;
} catch {
return {};
}
}
async function loadEnv(): Promise<void> {
const home = homedir();
const cwd = process.cwd();
const homeEnv = await loadEnvFile(path.join(home, ".tuzi-skills", ".env"));
const cwdEnv = await loadEnvFile(path.join(cwd, ".tuzi-skills", ".env"));
for (const [k, v] of Object.entries(homeEnv)) {
if (!process.env[k]) process.env[k] = v;
}
for (const [k, v] of Object.entries(cwdEnv)) {
if (!process.env[k]) process.env[k] = v;
}
}
function extractYamlFrontMatter(content: string): string | null {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
return match ? match[1] : null;
}
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
const config: Partial<ExtendConfig> = {};
const lines = yaml.split("\n");
let currentKey: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
const colonIdx = trimmed.indexOf(":");
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim();
if (value === "null" || value === "") {
value = "null";
}
if (key === "version") {
config.version = value === "null" ? 1 : parseInt(value, 10);
} else if (key === "default_provider") {
config.default_provider = value === "null" ? null : (value as Provider);
} else if (key === "default_quality") {
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
} else if (key === "default_aspect_ratio") {
const cleaned = value.replace(/['"]/g, "");
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
} else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
} else if (key === "default_image_api_dialect") {
const cleaned = value.replace(/['"]/g, "");
config.default_image_api_dialect =
cleaned === "null" ? null : (cleaned as OpenAIImageApiDialect);
} else if (key === "default_model") {
config.default_model = { google: null, openai: null, dashscope: null, replicate: null, tuzi: null };
currentKey = "default_model";
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate" || key === "tuzi")) {
const cleaned = value.replace(/['"]/g, "");
config.default_model![key] = cleaned === "null" ? null : cleaned;
}
}
}
return config;
}
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
const home = homedir();
const cwd = process.cwd();
const paths = [
path.join(cwd, ".tuzi-skills", "tuzi-image-gen", "EXTEND.md"),
path.join(home, ".tuzi-skills", "tuzi-image-gen", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = await readFile(p, "utf8");
const yaml = extractYamlFrontMatter(content);
if (!yaml) continue;
return parseSimpleYaml(yaml);
} catch {
continue;
}
}
return {};
}
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
return {
...args,
provider: args.provider ?? extend.default_provider ?? null,
quality: args.quality ?? extend.default_quality ?? null,
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
imageSize: args.imageSize ?? extend.default_image_size ?? null,
imageApiDialect: args.imageApiDialect ?? extend.default_image_api_dialect ?? null,
};
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = [];
for (const f of files) {
parts.push(await readFile(f, "utf8"));
}
return parts.join("\n\n");
}
async function readPromptFromStdin(): Promise<string | null> {
if (process.stdin.isTTY) return null;
try {
const t = await Bun.stdin.text();
const v = t.trim();
return v.length > 0 ? v : null;
} catch {
return null;
}
}
function normalizeOutputImagePath(p: string): string {
const full = path.resolve(p);
const ext = path.extname(full);
if (ext) return full;
return `${full}.png`;
}
function detectProvider(args: CliArgs): Provider {
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate" && args.provider !== "tuzi") {
throw new Error(
"参考图片需要支持该功能的服务商。请使用 --provider google(Gemini 多模态)、--provider openai(GPT Image 编辑)、--provider replicate 或 --provider tuzi。"
);
}
if (args.provider) return args.provider;
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
const hasOpenai = !!process.env.OPENAI_API_KEY;
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
const hasTuzi = !!process.env.TUZI_API_KEY;
if (args.referenceImages.length > 0) {
if (hasTuzi) return "tuzi";
if (hasGoogle) return "google";
if (hasOpenai) return "openai";
if (hasReplicate) return "replicate";
throw new Error(
"参考图片需要 Tuzi、Google、OpenAI 或 Replicate。请设置 TUZI_API_KEY、GOOGLE_API_KEY/GEMINI_API_KEY、OPENAI_API_KEY 或 REPLICATE_API_TOKEN,或移除 --ref。"
);
}
const available = [hasTuzi && "tuzi", hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!;
throw new Error(
"未找到 API 密钥。请设置 TUZI_API_KEY、GOOGLE_API_KEY、GEMINI_API_KEY、OPENAI_API_KEY、DASHSCOPE_API_KEY 或 REPLICATE_API_TOKEN。\n" +
"在 ~/.tuzi-skills/.env 或 <cwd>/.tuzi-skills/.env 中配置密钥。"
);
}
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
for (const refPath of referenceImages) {
const fullPath = path.resolve(refPath);
try {
await access(fullPath);
} catch {
throw new Error(`参考图片未找到: ${fullPath}`);
}
}
}
type ProviderModule = {
getDefaultModel: () => string;
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
validateArgs?: (model: string, args: CliArgs) => void;
};
function isRetryableGenerationError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
const nonRetryableMarkers = [
"Reference image",
"not supported",
"only supported",
"No API key found",
"is required",
];
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
}
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "google") {
return (await import("./providers/google")) as ProviderModule;
}
if (provider === "dashscope") {
return (await import("./providers/dashscope")) as ProviderModule;
}
if (provider === "replicate") {
return (await import("./providers/replicate")) as ProviderModule;
}
if (provider === "tuzi") {
return (await import("./providers/tuzi")) as ProviderModule;
}
return (await import("./providers/openai")) as ProviderModule;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
await loadEnv();
const extendConfig = await loadExtendConfig();
const mergedArgs = mergeConfig(args, extendConfig);
if (!mergedArgs.quality) mergedArgs.quality = "2k";
let prompt: string | null = mergedArgs.prompt;
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
if (!prompt) prompt = await readPromptFromStdin();
if (!prompt) {
console.error("错误: 提示词不能为空");
printUsage();
process.exitCode = 1;
return;
}
if (!mergedArgs.imagePath) {
console.error("错误: --image 参数必填");
printUsage();
process.exitCode = 1;
return;
}
if (mergedArgs.referenceImages.length > 0) {
await validateReferenceImages(mergedArgs.referenceImages);
}
const provider = detectProvider(mergedArgs);
const providerModule = await loadProviderModule(provider);
let model = mergedArgs.model;
if (!model && extendConfig.default_model) {
if (provider === "google") model = extendConfig.default_model.google ?? null;
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
if (provider === "tuzi") model = extendConfig.default_model.tuzi ?? null;
}
model = model || providerModule.getDefaultModel();
providerModule.validateArgs?.(model, mergedArgs);
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
let imageData: Uint8Array;
let retried = false;
while (true) {
try {
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
break;
} catch (e) {
if (!retried && isRetryableGenerationError(e)) {
retried = true;
console.error("生成失败,正在重试...");
continue;
}
throw e;
}
}
const dir = path.dirname(outputPath);
await mkdir(dir, { recursive: true });
await writeFile(outputPath, imageData);
if (mergedArgs.json) {
console.log(
JSON.stringify(
{
savedImage: outputPath,
provider,
model,
prompt: prompt.slice(0, 200),
},
null,
2
)
);
} else {
console.log(outputPath);
}
}
main().catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
console.error(msg);
process.exit(1);
});
import type { CliArgs } from "../types";
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
}
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
function getBaseUrl(): string {
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
return base.replace(/\/+$/g, "");
}
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 };
}
const STANDARD_SIZES: [number, number][] = [
[1024, 1024],
[1280, 720],
[720, 1280],
[1024, 768],
[768, 1024],
[1536, 1024],
[1024, 1536],
[1536, 864],
[864, 1536],
];
const STANDARD_SIZES_2K: [number, number][] = [
[1536, 1536],
[2048, 1152],
[1152, 2048],
[1536, 1024],
[1024, 1536],
[1536, 864],
[864, 1536],
[2048, 2048],
];
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const is2k = quality === "2k";
const defaultSize = is2k ? "1536*1536" : "1024*1024";
if (!ar) return defaultSize;
const parsed = parseAspectRatio(ar);
if (!parsed) return defaultSize;
const targetRatio = parsed.width / parsed.height;
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
let best = defaultSize;
let bestDiff = Infinity;
for (const [w, h] of sizes) {
const diff = Math.abs(w / h - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
best = `${w}*${h}`;
}
}
return best;
}
function normalizeSize(size: string): string {
return size.replace("x", "*");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY 未配置");
if (args.referenceImages.length > 0) {
throw new Error(
"DashScope 不支持参考图片。请使用 --provider google 配合 Gemini 多模态模型。"
);
}
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
},
],
},
parameters: {
prompt_extend: false,
size,
},
};
console.log(`正在使用 DashScope 生成图片 (${model})...`, { size });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DashScope API 错误 (${res.status}): ${err}`);
}
const result = await res.json() as {
output?: {
result_image?: string;
choices?: Array<{
message?: {
content?: Array<{ image?: string }>;
};
}>;
};
};
let imageData: string | null = null;
if (result.output?.result_image) {
imageData = result.output.result_image;
} else if (result.output?.choices?.[0]?.message?.content) {
const content = result.output.choices[0].message.content;
for (const item of content) {
if (item.image) {
imageData = item.image;
break;
}
}
}
if (!imageData) {
console.error("响应:", JSON.stringify(result, null, 2));
throw new Error("响应中无图片数据");
}
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
const imgRes = await fetch(imageData);
if (!imgRes.ok) throw new Error("图片下载失败");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
return Uint8Array.from(Buffer.from(imageData, "base64"));
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = [
"gemini-3-pro-image-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-image-preview",
];
const GOOGLE_IMAGEN_MODELS = [
"imagen-3.0-generate-002",
"imagen-3.0-generate-001",
];
export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
}
function normalizeGoogleModelId(model: string): string {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}
function isGoogleMultimodal(model: string): boolean {
const normalized = normalizeGoogleModelId(model);
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
}
function isGoogleImagen(model: string): boolean {
const normalized = normalizeGoogleModelId(model);
return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m));
}
function getGoogleApiKey(): string | null {
return process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
}
function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
return args.quality === "2k" ? "2K" : "1K";
}
function getGoogleBaseUrl(): string {
const base =
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
return base.replace(/\/+$/g, "");
}
function buildGoogleUrl(pathname: string): string {
const base = getGoogleBaseUrl();
const cleanedPath = pathname.replace(/^\/+/g, "");
if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`;
return `${base}/v1beta/${cleanedPath}`;
}
function toModelPath(model: string): string {
const modelId = normalizeGoogleModelId(model);
return `models/${modelId}`;
}
function getHttpProxy(): string | null {
return (
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
null
);
}
async function postGoogleJsonViaCurl<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const proxy = getHttpProxy();
const bodyStr = JSON.stringify(body);
const proxyArgs = proxy ? `-x "${proxy}"` : "";
const result = execSync(
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
);
const parsed = JSON.parse(result.toString()) as any;
if (parsed.error) {
throw new Error(
`Google API 错误 (${parsed.error.code}): ${parsed.error.message}`,
);
}
return parsed as T;
}
async function postGoogleJsonViaFetch<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Google API 错误 (${res.status}): ${err}`);
}
return (await res.json()) as T;
}
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY 或 GEMINI_API_KEY 未配置");
const url = buildGoogleUrl(pathname);
const proxy = getHttpProxy();
// When an HTTP proxy is detected, use curl instead of fetch.
// Bun's fetch has a known issue where long-lived connections through
// HTTP proxies get their sockets closed unexpectedly, causing image
// generation requests to fail with "socket connection was closed
// unexpectedly". Using curl as the HTTP client works around this.
if (proxy) {
return postGoogleJsonViaCurl<T>(url, apiKey, body);
}
return postGoogleJsonViaFetch<T>(url, apiKey, body);
}
function buildPromptWithAspect(
prompt: string,
ar: string | null,
quality: CliArgs["quality"],
): string {
let result = prompt;
if (ar) {
result += ` Aspect ratio: ${ar}.`;
}
if (quality === "2k") {
result += " High resolution 2048px.";
}
return result;
}
function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
if (!ar) return prompt;
return `${prompt} Aspect ratio: ${ar}.`;
}
async function readImageAsBase64(
p: string,
): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".gif") mimeType = "image/gif";
else if (ext === ".webp") mimeType = "image/webp";
return { data: buf.toString("base64"), mimeType };
}
function extractInlineImageData(response: {
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}): string | null {
for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) {
const data = part.inlineData?.data;
if (typeof data === "string" && data.length > 0) return data;
}
}
return null;
}
function extractPredictedImageData(response: {
predictions?: Array<any>;
generatedImages?: Array<any>;
}): string | null {
const candidates = [
...(response.predictions || []),
...(response.generatedImages || []),
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
if (typeof candidate.bytesBase64Encoded === "string")
return candidate.bytesBase64Encoded;
if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image;
if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes;
if (typeof image.bytesBase64Encoded === "string")
return image.bytesBase64Encoded;
if (typeof image.data === "string") return image.data;
}
}
return null;
}
async function generateWithGemini(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
const parts: Array<{
text?: string;
inlineData?: { data: string; mimeType: string };
}> = [];
for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath);
parts.push({ inlineData: { data, mimeType } });
}
parts.push({ text: promptWithAspect });
const imageConfig: { imageSize: "1K" | "2K" | "4K" } = {
imageSize: getGoogleImageSize(args),
};
console.log("正在使用 Gemini 生成图片...", imageConfig);
const response = await postGoogleJson<{
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}>(`${toModelPath(model)}:generateContent`, {
contents: [
{
role: "user",
parts,
},
],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig,
},
});
console.log("生成完成。");
const imageData = extractInlineImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
throw new Error("响应中无图片数据");
}
async function generateWithImagen(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
const fullPrompt = buildPromptWithAspect(
prompt,
args.aspectRatio,
args.quality,
);
const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") {
console.error(
"警告:Imagen 模型不支持 4K 图片尺寸,将使用 2K 代替。",
);
}
const parameters: Record<string, unknown> = {
sampleCount: args.n,
};
if (args.aspectRatio) {
parameters.aspectRatio = args.aspectRatio;
}
if (imageSize === "1K" || imageSize === "2K") {
parameters.imageSize = imageSize;
} else {
parameters.imageSize = "2K";
}
const response = await postGoogleJson<{
predictions?: Array<any>;
generatedImages?: Array<any>;
}>(`${toModelPath(model)}:predict`, {
instances: [
{
prompt: fullPrompt,
},
],
parameters,
});
const imageData = extractPredictedImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
throw new Error("响应中无图片数据");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
throw new Error(
"Imagen 模型不支持参考图片。请使用 gemini-3-pro-image-preview、gemini-3-flash-preview 或 gemini-3.1-flash-image-preview。",
);
}
return generateWithImagen(prompt, model, args);
}
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error(
"参考图片仅支持 Gemini 多模态模型。请使用 gemini-3-pro-image-preview、gemini-3-flash-preview 或 gemini-3.1-flash-image-preview。",
);
}
return generateWithGemini(prompt, model, args);
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs, OpenAIImageApiDialect } from "../types";
export function getDefaultModel(): string {
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-2";
}
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
type SizeMapping = { square: string; landscape: string; portrait: string };
type OpenAIGenerationsBody = Record<string, unknown>;
function isGptImageModel(model: string): boolean {
return model.includes("gpt-image");
}
function isGptImage2Model(model: string): boolean {
return model.includes("gpt-image-2");
}
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const width = parseFloat(match[1]!);
const height = parseFloat(match[2]!);
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function roundToMultiple(value: number, multiple: number): number {
return Math.max(multiple, Math.round(value / multiple) * multiple);
}
function buildGptImage2SizeFromAspectRatio(
ar: string | null,
quality: CliArgs["quality"]
): string {
const parsed = ar ? parseAspectRatio(ar) : null;
const ratio = parsed ? parsed.width / parsed.height : 1;
if (!parsed || Math.abs(ratio - 1) < 0.1) {
const edge = quality === "2k" ? 2048 : 1024;
return `${edge}x${edge}`;
}
const targetLongEdge = quality === "2k" ? 2048 : 1024;
let width: number;
let height: number;
if (ratio > 1) {
width = targetLongEdge;
height = roundToMultiple(width / ratio, 16);
} else {
height = targetLongEdge;
width = roundToMultiple(height * ratio, 16);
}
while (width * height < 655_360) {
if (ratio > 1) {
width += 16;
height = roundToMultiple(width / ratio, 16);
} else {
height += 16;
width = roundToMultiple(height * ratio, 16);
}
}
return `${width}x${height}`;
}
export function getOpenAISize(
model: string,
ar: string | null,
quality: CliArgs["quality"]
): string {
const isDalle3 = model.includes("dall-e-3");
const isDalle2 = model.includes("dall-e-2");
if (isDalle2) {
return "1024x1024";
}
if (isGptImage2Model(model)) {
return buildGptImage2SizeFromAspectRatio(ar, quality);
}
const sizes: SizeMapping = isDalle3
? {
square: "1024x1024",
landscape: "1792x1024",
portrait: "1024x1792",
}
: {
square: "1024x1024",
landscape: "1536x1024",
portrait: "1024x1536",
};
if (!ar) return sizes.square;
const parsed = parseAspectRatio(ar);
if (!parsed) return sizes.square;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) return sizes.square;
if (ratio > 1.5) return sizes.landscape;
if (ratio < 0.67) return sizes.portrait;
return sizes.square;
}
function parsePixelSize(value: string): { width: number; height: number } | null {
const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y !== 0) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
function parseImageApiDialect(value: string | null | undefined): OpenAIImageApiDialect | null {
if (value === "openai-native" || value === "ratio-metadata") {
return value;
}
return null;
}
export function getOpenAIImageApiDialect(
args: Pick<CliArgs, "imageApiDialect">
): OpenAIImageApiDialect {
return (
parseImageApiDialect(args.imageApiDialect)
?? parseImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT)
?? "openai-native"
);
}
export function inferAspectRatioFromSize(size: string | null): string | null {
if (!size) return null;
const parsed = parsePixelSize(size);
if (!parsed) return null;
const divisor = gcd(parsed.width, parsed.height);
return `${parsed.width / divisor}:${parsed.height / divisor}`;
}
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
if (!size) return null;
const parsed = parsePixelSize(size);
if (!parsed) return null;
const longestEdge = Math.max(parsed.width, parsed.height);
if (longestEdge <= 1024) return "1K";
if (longestEdge <= 2048) return "2K";
return "4K";
}
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
}
export function getOpenAIResolution(
args: Pick<CliArgs, "imageSize" | "size" | "quality">
): "1K" | "2K" | "4K" {
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
return args.imageSize;
}
const inferred = inferResolutionFromSize(args.size);
if (inferred) return inferred;
return args.quality === "normal" ? "1K" : "2K";
}
function getOpenAIQuality(
model: string,
quality: CliArgs["quality"]
): "standard" | "hd" | "medium" | "high" | null {
if (model.includes("dall-e-3")) {
return quality === "2k" ? "hd" : "standard";
}
if (isGptImageModel(model)) {
return quality === "2k" ? "high" : "medium";
}
return null;
}
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
const parsed = parseAspectRatio(ar);
if (!parsed) return null;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) return null;
return ratio > 1 ? "landscape" : "portrait";
}
export function buildOpenAIGenerationsBody(
prompt: string,
model: string,
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
): OpenAIGenerationsBody {
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
const aspectRatio = getOpenAIAspectRatio(args);
const metadata: Record<string, string> = {
resolution: getOpenAIResolution(args),
};
const orientation = getOrientationFromAspectRatio(aspectRatio);
if (orientation) metadata.orientation = orientation;
return {
model,
prompt,
size: aspectRatio,
metadata,
};
}
const body: OpenAIGenerationsBody = {
model,
prompt,
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
};
const resolvedQuality = getOpenAIQuality(model, args.quality);
if (resolvedQuality) {
body.quality = resolvedQuality;
}
return body;
}
export function validateArgs(model: string, args: CliArgs): void {
if (!isGptImage2Model(model)) return;
if (args.aspectRatio && !args.size) {
const parsed = parseAspectRatio(args.aspectRatio);
if (!parsed) {
throw new Error(`gpt-image-2 的宽高比无效: ${args.aspectRatio}`);
}
const ratio = parsed.width / parsed.height;
if (Math.max(ratio, 1 / ratio) > 3) {
throw new Error("gpt-image-2 的宽高比不能超过 3:1。");
}
}
if (!args.size) return;
const parsedSize = parsePixelSize(args.size);
if (!parsedSize) {
throw new Error(`gpt-image-2 的 --size 无效: ${args.size}。格式应为 <width>x<height>。`);
}
const { width, height } = parsedSize;
const totalPixels = width * height;
const ratio = Math.max(width, height) / Math.min(width, height);
if (Math.max(width, height) > 3840) {
throw new Error("gpt-image-2 的 --size 最长边不能超过 3840px。");
}
if (width % 16 !== 0 || height % 16 !== 0) {
throw new Error("gpt-image-2 的 --size 宽高都必须是 16 的倍数。");
}
if (ratio > 3) {
throw new Error("gpt-image-2 的 --size 长宽比不能超过 3:1。");
}
if (totalPixels < 655_360 || totalPixels > 8_294_400) {
throw new Error("gpt-image-2 的总像素必须在 655,360 到 8,294,400 之间。");
}
}
export async function generateImage(
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 Error("OPENAI_API_KEY 未配置");
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
}
const imageApiDialect = getOpenAIImageApiDialect(args);
if (args.referenceImages.length > 0) {
if (imageApiDialect !== "openai-native") {
throw new Error(
"使用参考图时暂不支持 ratio-metadata 方言。请改用 openai-native,或切换到 Google、Replicate、Tuzi 等支持参考图的后端。"
);
}
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
throw new Error(
"OpenAI 参考图片需要 GPT Image 模型。请使用 --model gpt-image-2(或其他 gpt-image 模型)。"
);
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
return generateWithOpenAIEdits(
baseURL,
apiKey,
prompt,
model,
size,
args.referenceImages,
args.quality
);
}
return generateWithOpenAIGenerations(
baseURL,
apiKey,
buildOpenAIGenerationsBody(prompt, model, args)
);
}
async function generateWithChatCompletions(
baseURL: string,
apiKey: string,
prompt: string,
model: string
): Promise<Uint8Array> {
const res = await fetch(`${baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API 错误: ${err}`);
}
const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };
const content = result.choices[0]?.message?.content ?? "";
const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
if (match) {
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
}
throw new Error("聊天补全响应中未找到图片");
}
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
body: OpenAIGenerationsBody
): Promise<Uint8Array> {
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 err = await res.text();
throw new Error(`OpenAI API 错误: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
async function generateWithOpenAIEdits(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
size: string,
referenceImages: string[],
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
form.append("size", size);
const resolvedQuality = getOpenAIQuality(model, quality);
if (resolvedQuality) {
form.append("quality", resolvedQuality);
}
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
const filename = path.basename(refPath);
const mimeType = getMimeType(filename);
const blob = new Blob([bytes], { type: mimeType });
form.append("image[]", blob, filename);
}
const res = await fetch(`${baseURL}/images/edits`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI 编辑 API 错误: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
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";
}
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
const img = result.data[0];
if (img?.b64_json) {
return Uint8Array.from(Buffer.from(img.b64_json, "base64"));
}
if (img?.url) {
const imgRes = await fetch(img.url);
if (!imgRes.ok) throw new Error("图片下载失败");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
throw new Error("响应中无图片数据");
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "google/nano-banana-pro";
const SYNC_WAIT_SECONDS = 60;
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_MS = 300_000;
const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
]);
export type ReplicateModelFamily =
| "nano-banana"
| "seedream45"
| "seedream5lite"
| "wan27image"
| "wan27imagepro"
| "unknown";
type PixelSize = {
width: number;
height: number;
};
type Seedream45Size = "2K" | "4K" | { width: number; height: number };
export function getDefaultModel(): string {
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiToken(): string | null {
return process.env.REPLICATE_API_TOKEN || null;
}
function getBaseUrl(): string {
const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com";
return base.replace(/\/+$/g, "");
}
function normalizeModelId(model: string): string {
return model.trim().toLowerCase().split(":")[0]!;
}
export function getModelFamily(model: string): ReplicateModelFamily {
const normalized = normalizeModelId(model);
if (
normalized === "google/nano-banana"
|| normalized === "google/nano-banana-pro"
|| normalized === "google/nano-banana-2"
) {
return "nano-banana";
}
if (normalized === "bytedance/seedream-4.5") {
return "seedream45";
}
if (normalized === "bytedance/seedream-5-lite") {
return "seedream5lite";
}
if (normalized === "wan-video/wan-2.7-image") {
return "wan27image";
}
if (normalized === "wan-video/wan-2.7-image-pro") {
return "wan27imagepro";
}
return "unknown";
}
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
const [ownerName, version] = model.split(":");
const parts = ownerName!.split("/");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(
`Replicate 模型格式无效: "${model}"。应为 "owner/name" 或 "owner/name:version"。`
);
}
return { owner: parts[0], name: parts[1], version: version || null };
}
function parsePixelSize(value: string): PixelSize | null {
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function parseAspectRatio(value: string): PixelSize | null {
const match = value.trim().match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y !== 0) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
function inferAspectRatioFromSize(size: string): string | null {
const parsed = parsePixelSize(size);
if (!parsed) return null;
const divisor = gcd(parsed.width, parsed.height);
const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;
if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {
return null;
}
return normalized;
}
function getQualityPreset(args: CliArgs): "normal" | "2k" {
return args.quality === "normal" ? "normal" : "2k";
}
function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {
if (aspectRatio === "match_input_image") {
return;
}
if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {
return;
}
throw new Error(
`Replicate 模型 ${model} 不支持宽高比 ${aspectRatio}。支持值:${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
);
}
function getRequestedAspectRatio(model: string, args: CliArgs): string | null {
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
return args.aspectRatio;
}
if (!args.size) return null;
const inferred = inferAspectRatioFromSize(args.size);
if (!inferred) {
throw new Error(
`Replicate 模型 ${model} 无法从 --size ${args.size} 推导出受支持的宽高比。请改用:${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
);
}
return inferred;
}
function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {
if (args.size) {
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate nano-banana 的 --size 必须是 WxH 格式,例如 1536x1024。");
}
const longestEdge = Math.max(parsed.width, parsed.height);
if (longestEdge <= 1024) return "1K";
if (longestEdge <= 2048) return "2K";
throw new Error("Replicate nano-banana 仅支持映射到 1K 或 2K 的尺寸。");
}
return getQualityPreset(args) === "normal" ? "1K" : "2K";
}
function resolveSeedream45Size(args: CliArgs): Seedream45Size {
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "2K" || upper === "4K") {
return upper;
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate Seedream 4.5 的 --size 必须是 2K、4K 或显式 WxH。");
}
if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {
throw new Error("Replicate Seedream 4.5 自定义尺寸要求宽高都在 1024 到 4096 之间。");
}
return parsed;
}
return getQualityPreset(args) === "normal" ? "2K" : "4K";
}
function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "2K" || upper === "3K") {
return upper;
}
throw new Error("Replicate Seedream 5 Lite 在当前工具中仅支持 2K 或 3K。");
}
return getQualityPreset(args) === "normal" ? "2K" : "3K";
}
function formatCustomWanSize(size: PixelSize): string {
return `${size.width}*${size.height}`;
}
function resolveWanSizeFromAspectRatio(aspectRatio: string, maxDimension: number): string {
const parsedRatio = parseAspectRatio(aspectRatio);
if (!parsedRatio) {
throw new Error(`Replicate Wan 的宽高比必须是 W:H 格式,收到:${aspectRatio}`);
}
const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);
const width = Math.max(1, Math.floor(parsedRatio.width * scale));
const height = Math.max(1, Math.floor(parsedRatio.height * scale));
return formatCustomWanSize({ width, height });
}
function resolveWanSize(
family: "wan27image" | "wan27imagepro",
args: CliArgs,
): "1K" | "2K" | "4K" | string {
const referenceMode = args.referenceImages.length > 0;
const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "1K" || upper === "2K" || upper === "4K") {
if (upper === "4K" && family !== "wan27imagepro") {
throw new Error("Replicate Wan 2.7 Image 仅支持 1K、2K 或不超过 2048px 的自定义尺寸。");
}
if (upper === "4K" && referenceMode) {
throw new Error("Replicate Wan 2.7 Image Pro 的 4K 仅支持文生图。请移除 --ref 或降低尺寸。");
}
return upper;
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate Wan 的 --size 必须是 1K、2K、4K 或显式 WxH。");
}
if (parsed.width > maxDimension || parsed.height > maxDimension) {
throw new Error(
`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} 自定义尺寸要求宽高都不超过 ${maxDimension}px。`
);
}
return formatCustomWanSize(parsed);
}
if (args.aspectRatio) {
return resolveWanSizeFromAspectRatio(
args.aspectRatio,
getQualityPreset(args) === "normal" ? 1024 : 2048,
);
}
return getQualityPreset(args) === "normal" ? "1K" : "2K";
}
function buildNanoBananaInput(
prompt: string,
model: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const input: Record<string, unknown> = {
prompt,
resolution: getNanoBananaResolution(args),
output_format: "png",
};
const aspectRatio = getRequestedAspectRatio(model, args);
if (aspectRatio) {
input.aspect_ratio = aspectRatio;
} else if (referenceImages.length > 0) {
input.aspect_ratio = "match_input_image";
}
if (referenceImages.length > 0) {
input.image_input = referenceImages;
}
return input;
}
function buildSeedreamInput(
family: "seedream45" | "seedream5lite",
prompt: string,
model: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);
const input: Record<string, unknown> = { prompt };
if (family === "seedream45" && typeof size === "object") {
input.size = "custom";
input.width = size.width;
input.height = size.height;
} else {
input.size = size;
}
if (referenceImages.length > 0) {
input.image_input = referenceImages;
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
input.aspect_ratio = args.aspectRatio;
} else if (referenceImages.length > 0 && family === "seedream45") {
input.aspect_ratio = "match_input_image";
}
return input;
}
function buildWanInput(
family: "wan27image" | "wan27imagepro",
prompt: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const input: Record<string, unknown> = {
prompt,
size: resolveWanSize(family, args),
};
if (referenceImages.length > 0) {
input.images = referenceImages;
}
return input;
}
export function validateArgs(model: string, args: CliArgs): void {
parseModelId(model);
if (args.n !== 1) {
throw new Error("Replicate 当前仅支持单次请求保存 1 张图片。请移除 --n,或使用 --n 1。");
}
const family = getModelFamily(model);
if (family === "nano-banana") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate nano-banana 最多支持 14 张参考图。");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
if (args.size) {
getRequestedAspectRatio(model, args);
getNanoBananaResolution(args);
}
return;
}
if (family === "seedream45") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate Seedream 4.5 最多支持 14 张参考图。");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
resolveSeedream45Size(args);
return;
}
if (family === "seedream5lite") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate Seedream 5 Lite 最多支持 14 张参考图。");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
resolveSeedream5LiteSize(args);
return;
}
if (family === "wan27image" || family === "wan27imagepro") {
if (args.referenceImages.length > 9) {
throw new Error("Replicate Wan 2.7 系列最多支持 9 张参考图。");
}
if (args.aspectRatio) {
const parsed = parseAspectRatio(args.aspectRatio);
if (!parsed) {
throw new Error(`Replicate Wan 的宽高比必须是 W:H 格式,收到:${args.aspectRatio}`);
}
}
resolveWanSize(family, args);
return;
}
if (args.referenceImages.length > 0 || args.aspectRatio || args.size) {
throw new Error(
`Replicate 模型 ${model} 不在当前兼容列表中。已适配的模型家族:google/nano-banana*、bytedance/seedream-4.5、bytedance/seedream-5-lite、wan-video/wan-2.7-image、wan-video/wan-2.7-image-pro。`
);
}
}
export function buildInput(
model: string,
prompt: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const family = getModelFamily(model);
if (family === "nano-banana") {
return buildNanoBananaInput(prompt, model, args, referenceImages);
}
if (family === "seedream45" || family === "seedream5lite") {
return buildSeedreamInput(family, prompt, model, args, referenceImages);
}
if (family === "wan27image" || family === "wan27imagepro") {
return buildWanInput(family, prompt, args, referenceImages);
}
return { prompt };
}
async function readImageAsDataUrl(p: string): Promise<string> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".gif") mimeType = "image/gif";
else if (ext === ".webp") mimeType = "image/webp";
return `data:${mimeType};base64,${buf.toString("base64")}`;
}
type PredictionResponse = {
id: string;
status: string;
output: unknown;
error: string | null;
urls?: { get?: string };
};
async function createPrediction(
apiToken: string,
model: { owner: string; name: string; version: string | null },
input: Record<string, unknown>,
sync: boolean,
): Promise<PredictionResponse> {
const baseUrl = getBaseUrl();
let url: string;
const body: Record<string, unknown> = { input };
if (model.version) {
url = `${baseUrl}/v1/predictions`;
body.version = model.version;
} else {
url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`;
}
const headers: Record<string, string> = {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
};
if (sync) {
headers.Prefer = `wait=${SYNC_WAIT_SECONDS}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Replicate API 错误 (${res.status}): ${err}`);
}
return (await res.json()) as PredictionResponse;
}
async function pollPrediction(apiToken: string, getUrl: string): Promise<PredictionResponse> {
const start = Date.now();
while (Date.now() - start < MAX_POLL_MS) {
const res = await fetch(getUrl, {
headers: { Authorization: `Bearer ${apiToken}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Replicate 轮询错误 (${res.status}): ${err}`);
}
const prediction = (await res.json()) as PredictionResponse;
if (prediction.status === "succeeded") return prediction;
if (prediction.status === "failed" || prediction.status === "canceled") {
throw new Error(`Replicate 预测${prediction.status === "failed" ? "失败" : "已取消"}: ${prediction.error || "未知错误"}`);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(`Replicate 预测超时,已等待 ${MAX_POLL_MS / 1000} 秒`);
}
export function extractOutputUrl(prediction: PredictionResponse): string {
const output = prediction.output;
if (typeof output === "string") return output;
if (Array.isArray(output)) {
if (output.length !== 1) {
throw new Error(`Replicate 返回了 ${output.length} 个输出,但当前工具仅支持单图保存。`);
}
const first = output[0];
if (typeof first === "string") return first;
}
if (output && typeof output === "object" && "url" in output) {
const url = (output as Record<string, unknown>).url;
if (typeof url === "string") return url;
}
throw new Error(`Replicate 输出格式异常: ${JSON.stringify(output)}`);
}
async function downloadImage(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`从 Replicate 下载图片失败: ${res.status}`);
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
const apiToken = getApiToken();
if (!apiToken) {
throw new Error("REPLICATE_API_TOKEN 未配置。请前往 https://replicate.com/account/api-tokens 获取");
}
const parsedModel = parseModelId(model);
validateArgs(model, args);
const refDataUrls: string[] = [];
for (const refPath of args.referenceImages) {
refDataUrls.push(await readImageAsDataUrl(refPath));
}
const input = buildInput(model, prompt, args, refDataUrls);
console.log(`正在使用 Replicate 生成图片 (${model})...`);
let prediction = await createPrediction(apiToken, parsedModel, input, true);
if (prediction.status !== "succeeded") {
if (!prediction.urls?.get) {
throw new Error("Replicate 预测未返回轮询 URL");
}
console.log("正在等待预测完成...");
prediction = await pollPrediction(apiToken, prediction.urls.get);
}
console.log("生成完成。");
const outputUrl = extractOutputUrl(prediction);
return downloadImage(outputUrl);
}
import assert from "node:assert/strict";
import { writeFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
generateImage,
getModelFamily,
resolveSeedreamSize,
resolveSyncSize,
validateArgs,
} from "./tuzi.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: "2k",
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
json: false,
help: false,
...overrides,
};
}
function useEnv(t: TestContext, values: Record<string, string | null>): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function createTempPng(t: TestContext): Promise<string> {
const filePath = path.join(
tmpdir(),
`tuzi-test-${Date.now()}-${Math.random().toString(36).slice(2)}.png`,
);
const pngBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/axRZQAAAABJRU5ErkJggg==",
"base64",
);
await writeFile(filePath, pngBytes);
t.after(async () => {
await unlink(filePath).catch(() => {});
});
return filePath;
}
test("Tuzi model-family detection recognizes Gemini, GPT Image, and Seedream variants", () => {
assert.equal(getModelFamily("gemini-3-pro-image-preview"), "gemini");
assert.equal(getModelFamily("gpt-image-2"), "gpt-image");
assert.equal(getModelFamily("doubao-seedream-5-0-260128"), "seedream5");
assert.equal(getModelFamily("doubao-seedream-4-5-251128"), "seedream45");
assert.equal(getModelFamily("doubao-seedream-4-0-250828"), "seedream40");
assert.equal(getModelFamily("doubao-seedream-3-0-t2i-250415"), "seedream30");
assert.equal(getModelFamily("bfl-flux-2-pro"), "unknown");
});
test("Tuzi resolves GPT Image sizes and reuses local gpt-image-2 validation rules", () => {
assert.equal(
resolveSyncSize("gpt-image-2", makeArgs({ aspectRatio: "16:9", quality: "2k" })),
"2048x1152",
);
assert.equal(
resolveSyncSize("gpt-image-2", makeArgs({ aspectRatio: "9:16", quality: "2k" })),
"1152x2048",
);
assert.doesNotThrow(() =>
validateArgs("gpt-image-2", makeArgs({ size: "3840x2160" })),
);
assert.throws(
() => validateArgs("gpt-image-2", makeArgs({ size: "1234x777" })),
/16 的倍数/,
);
assert.throws(
() => validateArgs("gpt-image-2", makeArgs({ aspectRatio: "4:1" })),
/3:1/,
);
});
test("Tuzi Seedream size selection follows family-specific rules", () => {
assert.equal(
resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ imageSize: "3K" })),
"3K",
);
assert.equal(
resolveSeedreamSize("doubao-seedream-4-5-251128", makeArgs({ size: "4K" })),
"4K",
);
assert.equal(
resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })),
"1K",
);
assert.equal(
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ size: "1024x1024" })),
"1024x1024",
);
assert.throws(
() => resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ imageSize: "4K" })),
/2K、3K/,
);
assert.throws(
() => resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })),
/显式 WxH/,
);
assert.throws(
() => validateArgs("doubao-seedream-4-5-251128", makeArgs({ aspectRatio: "16:9" })),
/不直接支持 --ar/,
);
});
test("Tuzi Seedream reference-image validation matches supported families", () => {
assert.doesNotThrow(() =>
validateArgs(
"doubao-seedream-4-5-251128",
makeArgs({ referenceImages: ["a.png", "b.png"], imageSize: "2K" }),
),
);
assert.throws(
() =>
validateArgs(
"doubao-seedream-3-0-t2i-250415",
makeArgs({ referenceImages: ["a.png"], size: "1024x1024" }),
),
/不支持参考图片/,
);
assert.throws(
() =>
validateArgs(
"doubao-seedream-5-0-260128",
makeArgs({ referenceImages: new Array(15).fill("a.png"), imageSize: "2K" }),
),
/最多支持 14 张参考图/,
);
assert.throws(
() =>
validateArgs(
"doubao-seededit-3-0-i2i-250628",
makeArgs({ size: "1024x1024" }),
),
/已不再受支持/,
);
});
test("Tuzi generateImage uses resolved GPT Image sizes in the sync request body", async (t) => {
useEnv(t, { TUZI_API_KEY: "test-key", TUZI_BASE_URL: null });
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ input: string; init?: RequestInit }> = [];
globalThis.fetch = async (input, init) => {
calls.push({ input: String(input), init });
return Response.json({
data: [
{
url: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString("base64"),
},
],
});
};
const image = await generateImage(
"A cinematic skyline",
"gpt-image-2",
makeArgs({ aspectRatio: "16:9", quality: "2k" }),
);
assert.deepEqual([...image], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
assert.equal(calls.length, 1);
assert.equal(calls[0]?.input, "https://api.tu-zi.com/v1/images/generations");
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
assert.equal(requestBody.model, "gpt-image-2");
assert.equal(requestBody.size, "2048x1152");
assert.equal("response_format" in requestBody, false);
});
test("Tuzi gpt-image reference images use OpenAI-style edits multipart requests", async (t) => {
useEnv(t, { TUZI_API_KEY: "test-key", TUZI_BASE_URL: null });
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const refPath = await createTempPng(t);
const calls: Array<{ input: string; init?: RequestInit }> = [];
globalThis.fetch = async (input, init) => {
calls.push({ input: String(input), init });
return Response.json({
data: [
{
b64_json: Buffer.from([1, 2, 3]).toString("base64"),
},
],
});
};
const image = await generateImage(
"Turn this into a watercolor illustration",
"gpt-image-2",
makeArgs({ aspectRatio: "16:9", quality: "2k", referenceImages: [refPath] }),
);
assert.deepEqual([...image], [1, 2, 3]);
assert.equal(calls.length, 1);
assert.equal(calls[0]?.input, "https://api.tu-zi.com/v1/images/edits");
const form = calls[0]?.init?.body as FormData;
assert.ok(form instanceof FormData);
assert.equal(form.get("model"), "gpt-image-2");
assert.equal(form.get("prompt"), "Turn this into a watercolor illustration");
assert.equal(form.get("size"), "2048x1152");
assert.equal(form.get("quality"), "2k");
const images = form.getAll("image");
assert.equal(images.length, 1);
assert.equal(images[0] instanceof File, true);
});
test("Tuzi keeps response_format for non-GPT sync models and decodes raw base64 image payloads", async (t) => {
useEnv(t, { TUZI_API_KEY: "test-key", TUZI_BASE_URL: null });
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ input: string; init?: RequestInit }> = [];
globalThis.fetch = async (input, init) => {
calls.push({ input: String(input), init });
return Response.json({
data: [
{
url: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]).toString("base64"),
},
],
});
};
const image = await generateImage(
"A cozy cafe interior",
"gemini-3-pro-image-preview",
makeArgs({ aspectRatio: "4:3" }),
);
assert.deepEqual([...image], [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
assert.equal(calls.length, 1);
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
assert.equal(requestBody.model, "gemini-3-pro-image-preview");
assert.equal(requestBody.size, "4x3");
assert.equal(requestBody.response_format, "url");
});
import path from "node:path";
import { readFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import type { CliArgs } from "../types";
import { getOpenAISize, validateArgs as validateOpenAIArgs } from "./openai";
const DEFAULT_MODEL = "gemini-3-pro-image-preview";
const POLL_INTERVAL_MS = 5000;
const MAX_POLL_ATTEMPTS = 360;
export type TuziModelFamily =
| "gemini"
| "gpt-image"
| "seedream5"
| "seedream45"
| "seedream40"
| "seedream30"
| "unknown";
export function getDefaultModel(): string {
return process.env.TUZI_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiKey(): string | null {
return process.env.TUZI_API_KEY || null;
}
function getBaseUrl(): string {
const base = process.env.TUZI_BASE_URL || "https://api.tu-zi.com/v1";
return base.replace(/\/+$/g, "");
}
function normalizeModelId(model: string): string {
return model.trim().toLowerCase();
}
export function getModelFamily(model: string): TuziModelFamily {
const normalized = normalizeModelId(model);
if (/^doubao-seedream-5-0(?:-lite)?-\d+$/.test(normalized)) return "seedream5";
if (/^doubao-seedream-4-5-\d+$/.test(normalized)) return "seedream45";
if (/^doubao-seedream-4-0-\d+$/.test(normalized)) return "seedream40";
if (/^doubao-seedream-3-0-t2i-\d+$/.test(normalized)) return "seedream30";
if (normalized.includes("gpt-image")) return "gpt-image";
if (normalized.includes("gemini")) return "gemini";
return "unknown";
}
const ASYNC_MODEL_IDS = [
"gemini-3-pro-image-preview-async",
"gemini-3-pro-image-preview-2k-async",
"gemini-3-pro-image-preview-4k-async",
"mj-imagine",
];
function isAsyncModel(model: string): boolean {
const lower = model.toLowerCase();
return ASYNC_MODEL_IDS.some((id) => lower.includes(id.toLowerCase()));
}
const QUALITY_MODELS = [
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image-preview",
];
function supportsQuality(model: string): boolean {
return QUALITY_MODELS.some((id) => model === id);
}
function arToSize(ar: string | null): string | null {
if (!ar) return null;
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = Math.round(parseFloat(match[1]!));
const h = Math.round(parseFloat(match[2]!));
return `${w}x${h}`;
}
function parsePixelSize(value: string): { width: number; height: number } | null {
const match = value.trim().match(/^(\d+)\s*[xX]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function normalizePixelSize(value: string): string | null {
const parsed = parsePixelSize(value);
if (!parsed) return null;
return `${parsed.width}x${parsed.height}`;
}
function normalizeSizePreset(value: string): string | null {
const upper = value.trim().toUpperCase();
if (upper === "ADAPTIVE") return "adaptive";
if (upper === "1K" || upper === "2K" || upper === "3K" || upper === "4K") return upper;
return null;
}
function normalizeSizeValue(value: string): string | null {
return normalizeSizePreset(value) ?? normalizePixelSize(value);
}
function mapQuality(args: CliArgs): string | null {
if (args.imageSize) return args.imageSize.toLowerCase();
if (args.quality === "2k") return "2k";
if (args.quality === "normal") return "1k";
return null;
}
function isRemovedSeededitModel(model: string): boolean {
return /^doubao-seededit-3-0-i2i-\d+$/.test(normalizeModelId(model));
}
function assertSupportedSeedreamModel(model: string): void {
if (isRemovedSeededitModel(model)) {
throw new Error(
`${model} 已不再受支持。当前工具仅支持 Seedream 5.0 / 4.5 / 4.0 / 3.0。`
);
}
}
function isSeedreamFamily(family: TuziModelFamily): boolean {
return family === "seedream5" || family === "seedream45" || family === "seedream40" || family === "seedream30";
}
function supportsSeedreamReferenceImages(model: string): boolean {
const family = getModelFamily(model);
return family === "seedream5" || family === "seedream45" || family === "seedream40";
}
function getDefaultSeedreamSize(model: string, args: CliArgs): string {
assertSupportedSeedreamModel(model);
const family = getModelFamily(model);
if (family === "seedream5") return "2K";
if (family === "seedream45") return "2K";
if (family === "seedream40") return args.quality === "normal" ? "1K" : "2K";
if (family === "seedream30") return args.quality === "2k" ? "2048x2048" : "1024x1024";
return "2K";
}
export function resolveSeedreamSize(model: string, args: CliArgs): string {
assertSupportedSeedreamModel(model);
const family = getModelFamily(model);
const requested = args.size || args.imageSize || null;
const normalized = requested ? normalizeSizeValue(requested) : null;
if (args.aspectRatio && !args.size && !args.imageSize) {
throw new Error(
"Tuzi Seedream 模型不直接支持 --ar。请改用 --size 2048x1152 这类显式尺寸,或使用 --imageSize 2K/3K/4K。"
);
}
if (!normalized) {
return getDefaultSeedreamSize(model, args);
}
if (family === "seedream30") {
const pixelSize = normalizePixelSize(normalized);
if (!pixelSize) {
throw new Error("Tuzi Seedream 3.0 仅支持显式 WxH 尺寸,例如 1024x1024。");
}
return pixelSize;
}
if (family === "seedream5") {
if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") {
throw new Error("Tuzi Seedream 5.0 仅支持 2K、3K 或显式 WxH 尺寸。");
}
return normalized;
}
if (family === "seedream45") {
if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") {
throw new Error("Tuzi Seedream 4.5 仅支持 2K、4K 或显式 WxH 尺寸。");
}
return normalized;
}
if (family === "seedream40") {
if (normalized === "3K" || normalized === "adaptive") {
throw new Error("Tuzi Seedream 4.0 仅支持 1K、2K、4K 或显式 WxH 尺寸。");
}
return normalized;
}
if (normalized === "adaptive") {
throw new Error("Tuzi Seedream 当前不支持 adaptive size。");
}
if (normalized === "1K" || normalized === "3K" || normalized === "4K") {
throw new Error("未知的 Tuzi Seedream 模型 ID。请使用已知模型,或改用显式 WxH 尺寸。");
}
return normalized;
}
export function validateArgs(model: string, args: CliArgs): void {
const family = getModelFamily(model);
if (family === "gpt-image") {
validateOpenAIArgs(model, args);
return;
}
if (!isSeedreamFamily(family) && !isRemovedSeededitModel(model)) {
return;
}
assertSupportedSeedreamModel(model);
const refCount = args.referenceImages.length;
if (refCount === 0) {
resolveSeedreamSize(model, args);
return;
}
if (family === "unknown") {
throw new Error(
"Tuzi Seedream 参考图需要明确的 Seedream 模型 ID。请使用 Seedream 5.0 / 4.5 / 4.0 的正式模型名。"
);
}
if (!supportsSeedreamReferenceImages(model)) {
throw new Error(`${model} 不支持参考图片。`);
}
if (refCount > 14) {
throw new Error(`${model} 最多支持 14 张参考图。`);
}
resolveSeedreamSize(model, args);
}
export function resolveSyncSize(model: string, args: CliArgs): string | null {
const family = getModelFamily(model);
if (family === "gpt-image") {
return args.size || getOpenAISize(model, args.aspectRatio, args.quality);
}
if (isSeedreamFamily(family) || isRemovedSeededitModel(model)) {
return resolveSeedreamSize(model, args);
}
return args.size || arToSize(args.aspectRatio);
}
type SyncResponse = { data: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> };
type AsyncSubmitResponse = { id: string; status: string; progress?: number; error?: unknown };
type AsyncPollResponse = { id: string; status: string; progress?: number; video_url?: string; url?: string; error?: unknown };
type SyncImageResult = { kind: "bytes"; bytes: Uint8Array } | { kind: "url"; url: string };
type PreparedReferenceImage = { bytes: Buffer; mime: string; filename: string };
async function downloadImage(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`图片下载失败: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
function decodeBase64Bytes(value: string): Uint8Array | null {
const normalized = value.replace(/\s+/g, "");
if (!normalized || !/^[A-Za-z0-9+/=]+$/.test(normalized)) return null;
const remainder = normalized.length % 4;
if (remainder === 1) return null;
const padded = remainder === 0 ? normalized : `${normalized}${"=".repeat(4 - remainder)}`;
try {
const bytes = Uint8Array.from(Buffer.from(padded, "base64"));
return bytes.length > 0 ? bytes : null;
} catch {
return null;
}
}
function looksLikeImageBytes(bytes: Uint8Array): boolean {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return true;
if (
bytes.length >= 8
&& bytes[0] === 0x89
&& bytes[1] === 0x50
&& bytes[2] === 0x4e
&& bytes[3] === 0x47
&& bytes[4] === 0x0d
&& bytes[5] === 0x0a
&& bytes[6] === 0x1a
&& bytes[7] === 0x0a
) return true;
if (
bytes.length >= 6
&& bytes[0] === 0x47
&& bytes[1] === 0x49
&& bytes[2] === 0x46
&& bytes[3] === 0x38
&& (bytes[4] === 0x37 || bytes[4] === 0x39)
&& bytes[5] === 0x61
) return true;
if (
bytes.length >= 12
&& 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 true;
return false;
}
function decodeDataUrlImage(value: string): Uint8Array | null {
const match = value.match(/^data:image\/[^;]+;base64,([A-Za-z0-9+/=\s]+)$/i);
if (!match) return null;
return decodeBase64Bytes(match[1]!);
}
function extractSyncImage(result: SyncResponse): SyncImageResult {
const img = result.data?.[0];
if (img?.revised_prompt?.includes("PROHIBITED_CONTENT")) {
throw new Error("内容被拒绝:包含违规内容");
}
if (img?.revised_prompt?.includes("NO_IMAGE")) {
throw new Error("模型未生成图片,请尝试更明确的提示词。");
}
if (img?.b64_json) {
const bytes = decodeBase64Bytes(img.b64_json);
if (!bytes) throw new Error("响应中的 b64_json 无法解析");
return { kind: "bytes", bytes };
}
if (img?.url) {
const dataUrlBytes = decodeDataUrlImage(img.url);
if (dataUrlBytes) return { kind: "bytes", bytes: dataUrlBytes };
const rawBase64Bytes = decodeBase64Bytes(img.url);
if (rawBase64Bytes && looksLikeImageBytes(rawBase64Bytes)) {
return { kind: "bytes", bytes: rawBase64Bytes };
}
if (/^https?:\/\//i.test(img.url)) {
return { kind: "url", url: img.url };
}
throw new Error("响应中的图片字段既不是有效 URL,也不是可识别的 base64 图片数据");
}
throw new Error("响应中无图片数据");
}
function parseError(error: unknown): string {
if (!error) return "未知错误";
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null) {
const e = error as Record<string, unknown>;
if (typeof e.message === "string") return e.message;
if (typeof e.code === "string" && typeof e.message === "string") return `${e.code}: ${e.message}`;
}
return String(error);
}
const MAX_REF_IMAGE_BYTES = 1024 * 1024;
function runCmd(cmd: string, args: string[]): Promise<{ code: number }> {
return new Promise((res) => {
const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
proc.on("close", (code) => res({ code: code ?? 1 }));
proc.on("error", () => res({ code: 1 }));
});
}
async function compressToJpeg(filePath: string): Promise<Buffer | null> {
const tmp = path.join(tmpdir(), `tuzi-ref-${Date.now()}.jpg`);
try {
if (process.platform === "darwin") {
const { code } = await runCmd("sips", ["-s", "format", "jpeg", "-s", "formatOptions", "70", filePath, "--out", tmp]);
if (code === 0) return await readFile(tmp);
}
const { code } = await runCmd("convert", [filePath, "-quality", "70", tmp]);
if (code === 0) return await readFile(tmp);
return null;
} finally {
await unlink(tmp).catch(() => {});
}
}
function getImageMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "image/png";
}
async function prepareReferenceImage(filePath: string): Promise<PreparedReferenceImage> {
let bytes = await readFile(filePath);
let mime = getImageMimeType(filePath);
let filename = path.basename(filePath);
if (bytes.length > MAX_REF_IMAGE_BYTES && mime !== "image/gif") {
const compressed = await compressToJpeg(filePath);
if (compressed && compressed.length < bytes.length) {
console.log(`参考图 ${path.basename(filePath)} 已压缩: ${bytes.length} → ${compressed.length} bytes`);
bytes = compressed;
mime = "image/jpeg";
filename = `${path.parse(filename).name}.jpg`;
}
}
return { bytes, mime, filename };
}
async function readImageAsBase64DataUrl(filePath: string): Promise<string> {
const prepared = await prepareReferenceImage(filePath);
return `data:${prepared.mime};base64,${prepared.bytes.toString("base64")}`;
}
function getGptImageEditSize(model: string, args: CliArgs): string {
return args.size || getOpenAISize(model, args.aspectRatio, args.quality);
}
function getGptImageEditQuality(args: CliArgs): string | null {
if (args.imageSize) return args.imageSize.toLowerCase();
if (args.quality === "2k") return "2k";
return null;
}
async function extractSyncResult(result: SyncResponse): Promise<Uint8Array> {
const image = extractSyncImage(result);
console.log("生成完成。");
if (image.kind === "bytes") return image.bytes;
return downloadImage(image.url);
}
async function generateGptImageEdits(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
form.append("size", getGptImageEditSize(model, args));
const quality = getGptImageEditQuality(args);
if (quality) form.append("quality", quality);
for (const refPath of args.referenceImages) {
const prepared = await prepareReferenceImage(refPath);
const blob = new Blob([prepared.bytes], { type: prepared.mime });
form.append("image", blob, prepared.filename);
}
console.log(`正在使用 Tuzi 生成图片 (${model})...`);
const res = await fetch(`${baseURL}/images/edits`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Tuzi API 错误 (${res.status}): ${err}`);
}
const result = (await res.json()) as SyncResponse;
return extractSyncResult(result);
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("TUZI_API_KEY 未配置。请前往 https://api.tu-zi.com/token 获取(视频教程:https://www.bilibili.com/video/BV1k4PqzPEKz/)");
const baseURL = getBaseUrl();
validateArgs(model, args);
const family = getModelFamily(model);
if (family === "gpt-image" && args.referenceImages.length > 0) {
return generateGptImageEdits(baseURL, apiKey, prompt, model, args);
}
if (isAsyncModel(model)) {
return generateAsync(baseURL, apiKey, prompt, model, args);
}
return generateSync(baseURL, apiKey, prompt, model, args);
}
async function generateSync(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const body: Record<string, unknown> = {
model,
prompt,
};
const family = getModelFamily(model);
if (family !== "gpt-image") {
body.response_format = "url";
}
const size = resolveSyncSize(model, args);
if (size) body.size = size;
if (supportsQuality(model)) {
const q = mapQuality(args);
if (q) body.quality = q;
}
if (args.referenceImages.length > 0) {
const refs: string[] = [];
for (const refPath of args.referenceImages) {
refs.push(await readImageAsBase64DataUrl(refPath));
}
body.image = refs;
}
if (args.n > 1) body.n = args.n;
console.log(`正在使用 Tuzi 生成图片 (${model})...`);
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 err = await res.text();
throw new Error(`Tuzi API 错误 (${res.status}): ${err}`);
}
const result = (await res.json()) as SyncResponse;
return extractSyncResult(result);
}
async function generateAsync(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const normalizedBase = baseURL.replace(/\/v1\/?$/, "");
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
const size = args.size || arToSize(args.aspectRatio) || "1:1";
form.append("size", size);
if (args.referenceImages.length > 0) {
for (let i = 0; i < args.referenceImages.length; i++) {
const refPath = args.referenceImages[i]!;
const prepared = await prepareReferenceImage(refPath);
const blob = new Blob([prepared.bytes], { type: prepared.mime });
const ext = path.extname(prepared.filename) || ".png";
form.append("input_reference", blob, `reference-${i + 1}${ext}`);
}
}
console.log(`正在提交 Tuzi 异步图片任务 (${model})...`);
const submitRes = await fetch(`${normalizedBase}/v1/videos`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
if (!submitRes.ok) {
const err = await submitRes.text();
throw new Error(`Tuzi 异步提交错误 (${submitRes.status}): ${err}`);
}
const submitData = (await submitRes.json()) as AsyncSubmitResponse;
if (submitData.status === "failed") {
throw new Error(parseError(submitData.error));
}
const taskId = submitData.id;
if (!taskId) throw new Error("Tuzi API 未返回任务 ID");
console.log(`任务已提交 (id: ${taskId}),正在轮询结果...`);
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
const pollRes = await fetch(`${normalizedBase}/v1/videos/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!pollRes.ok) {
const err = await pollRes.text();
throw new Error(`Tuzi 轮询错误 (${pollRes.status}): ${err}`);
}
const status = (await pollRes.json()) as AsyncPollResponse;
if (attempt % 6 === 0) {
console.log(`轮询中... 状态=${status.status}, 进度=${status.progress ?? 0}`);
}
if (status.status === "completed") {
const url = status.video_url || status.url;
if (!url) throw new Error("Tuzi API 未返回图片 URL");
console.log("异步生成完成。");
return downloadImage(url);
}
if (status.status === "failed") {
throw new Error(parseError(status.error));
}
}
throw new Error(`Tuzi 异步生成超时,已等待 ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000} 秒`);
}
export type Provider = "google" | "openai" | "dashscope" | "replicate" | "tuzi";
export type Quality = "normal" | "2k";
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
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;
imageSize: string | null;
imageApiDialect: OpenAIImageApiDialect | null;
referenceImages: string[];
n: number;
json: boolean;
help: boolean;
};
export type ExtendConfig = {
version: number;
default_provider: Provider | null;
default_quality: Quality | null;
default_aspect_ratio: string | null;
default_image_size: "1K" | "2K" | "4K" | null;
default_image_api_dialect: OpenAIImageApiDialect | null;
default_model: {
google: string | null;
openai: string | null;
dashscope: string | null;
replicate: string | null;
tuzi: string | null;
};
};