mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
chore(baoyu-image-gen): add deprecation notice redirecting to baoyu-imagine
This commit is contained in:
@@ -0,0 +1,408 @@
|
|||||||
|
---
|
||||||
|
name: baoyu-image-gen
|
||||||
|
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||||
|
version: 1.56.4
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
||||||
|
requires:
|
||||||
|
anyBins:
|
||||||
|
- bun
|
||||||
|
- npx
|
||||||
|
---
|
||||||
|
|
||||||
|
> **⚠️ DEPRECATED**: This skill has been migrated to `baoyu-imagine`. Print a bold red warning to the user **in the user's language** (use ANSI escape `\033[1;31m`) stating that `baoyu-image-gen` has been migrated to `baoyu-imagine` and they should use `baoyu-imagine` instead. Then STOP — do NOT continue executing this skill.
|
||||||
|
|
||||||
|
# Image Generation (AI SDK)
|
||||||
|
|
||||||
|
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||||
|
|
||||||
|
## Script Directory
|
||||||
|
|
||||||
|
**Agent Execution**:
|
||||||
|
1. `{baseDir}` = this SKILL.md file's directory
|
||||||
|
2. Script path = `{baseDir}/scripts/main.ts`
|
||||||
|
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||||
|
|
||||||
|
## Step 0: Load Preferences ⛔ BLOCKING
|
||||||
|
|
||||||
|
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
|
||||||
|
|
||||||
|
Check EXTEND.md existence (priority: project → user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS, Linux, WSL, Git Bash
|
||||||
|
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
||||||
|
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PowerShell (Windows)
|
||||||
|
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" }
|
||||||
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "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](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 |
|
||||||
|
|------|----------|
|
||||||
|
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||||
|
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||||
|
|
||||||
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||||
|
|
||||||
|
Schema: `references/config/preferences-schema.md`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
|
|
||||||
|
# With aspect ratio
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
|
||||||
|
|
||||||
|
# High quality
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||||
|
|
||||||
|
# From prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
|
||||||
|
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||||
|
|
||||||
|
# With reference images (explicit provider/model)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||||
|
|
||||||
|
# Azure OpenAI (model means deployment name)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5
|
||||||
|
|
||||||
|
# OpenRouter (recommended default model)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
|
||||||
|
|
||||||
|
# OpenRouter with reference images
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||||
|
|
||||||
|
# Specific provider
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||||
|
|
||||||
|
# DashScope (阿里通义万象)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||||
|
|
||||||
|
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||||
|
|
||||||
|
# DashScope legacy Qwen fixed-size model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||||
|
|
||||||
|
# MiniMax with subject reference (best for character/portrait consistency)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||||
|
|
||||||
|
# MiniMax with custom size (documented for image-01)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic poster" --image out.jpg --provider minimax --model image-01 --size 1536x1024
|
||||||
|
|
||||||
|
# Replicate (google/nano-banana-pro)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
|
# Replicate with specific model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
|
||||||
|
# Batch mode with saved prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
|
# Batch mode with explicit worker count
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch File Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9",
|
||||||
|
"quality": "2k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diagram",
|
||||||
|
"promptFiles": ["prompts/diagram.md"],
|
||||||
|
"image": "out/diagram.png",
|
||||||
|
"ref": ["references/original.png"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--prompt <text>`, `-p` | Prompt text |
|
||||||
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
|
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||||
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
|
| `--provider google\|openai\|azure\|openrouter\|dashscope\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||||
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; MiniMax: `image-01`) |
|
||||||
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
|
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||||
|
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||||
|
| `--n <count>` | Number of images |
|
||||||
|
| `--json` | JSON output |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `OPENAI_API_KEY` | OpenAI API key |
|
||||||
|
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key |
|
||||||
|
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
||||||
|
| `GOOGLE_API_KEY` | Google API key |
|
||||||
|
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API key |
|
||||||
|
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||||
|
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||||
|
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||||
|
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
||||||
|
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name |
|
||||||
|
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible alias for Azure default deployment/model name |
|
||||||
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||||
|
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||||
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
||||||
|
| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) |
|
||||||
|
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||||
|
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||||
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||||
|
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||||
|
| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint |
|
||||||
|
| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) |
|
||||||
|
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution |
|
||||||
|
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
|
||||||
|
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||||
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||||
|
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) |
|
||||||
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||||
|
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||||
|
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||||
|
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||||
|
|
||||||
|
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||||
|
|
||||||
|
## Model Resolution
|
||||||
|
|
||||||
|
Model priority (highest → lowest), applies to all providers:
|
||||||
|
|
||||||
|
1. CLI flag: `--model <id>`
|
||||||
|
2. EXTEND.md: `default_model.[provider]`
|
||||||
|
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
||||||
|
4. Built-in default
|
||||||
|
|
||||||
|
For Azure, `--model` / `default_model.azure` should be the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var, and `AZURE_OPENAI_IMAGE_MODEL` remains as a backward-compatible alias.
|
||||||
|
|
||||||
|
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
### DashScope Models
|
||||||
|
|
||||||
|
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
||||||
|
|
||||||
|
Official DashScope model families:
|
||||||
|
|
||||||
|
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
||||||
|
- Free-form `size` in `宽*高` format
|
||||||
|
- Total pixels must stay between `512*512` and `2048*2048`
|
||||||
|
- Default size is approximately `1024*1024`
|
||||||
|
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
||||||
|
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
||||||
|
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
||||||
|
- Default size is `1664*928`
|
||||||
|
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
||||||
|
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
||||||
|
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
||||||
|
|
||||||
|
When translating CLI args into DashScope behavior:
|
||||||
|
|
||||||
|
- `--size` wins over `--ar`
|
||||||
|
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
||||||
|
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
||||||
|
- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
||||||
|
|
||||||
|
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
||||||
|
|
||||||
|
| Ratio | `normal` | `2k` |
|
||||||
|
|-------|----------|------|
|
||||||
|
| `1:1` | `1024*1024` | `1536*1536` |
|
||||||
|
| `2:3` | `768*1152` | `1024*1536` |
|
||||||
|
| `3:2` | `1152*768` | `1536*1024` |
|
||||||
|
| `3:4` | `960*1280` | `1080*1440` |
|
||||||
|
| `4:3` | `1280*960` | `1440*1080` |
|
||||||
|
| `9:16` | `720*1280` | `1080*1920` |
|
||||||
|
| `16:9` | `1280*720` | `1920*1080` |
|
||||||
|
| `21:9` | `1344*576` | `2048*872` |
|
||||||
|
|
||||||
|
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
||||||
|
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||||
|
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||||
|
|
||||||
|
### MiniMax Models
|
||||||
|
|
||||||
|
Use `--model image-01` or set `default_model.minimax` / `MINIMAX_IMAGE_MODEL` when the user wants MiniMax image generation.
|
||||||
|
|
||||||
|
Official MiniMax image model options currently documented in the API reference:
|
||||||
|
|
||||||
|
- `image-01` (recommended default)
|
||||||
|
- Supports text-to-image and subject-reference image generation
|
||||||
|
- Supports official `aspect_ratio` values: `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9`
|
||||||
|
- Supports documented custom `width` / `height` output sizes when using `--size <WxH>`
|
||||||
|
- `width` and `height` must both be between `512` and `2048`, and both must be divisible by `8`
|
||||||
|
- `image-01-live`
|
||||||
|
- Lower-latency variant
|
||||||
|
- Use `--ar` for sizing; MiniMax documents custom `width` / `height` as only effective for `image-01`
|
||||||
|
|
||||||
|
MiniMax subject reference notes:
|
||||||
|
|
||||||
|
- `--ref` files are sent as MiniMax `subject_reference`
|
||||||
|
- MiniMax docs currently describe `subject_reference[].type` as `character`
|
||||||
|
- Official docs say `image_file` supports public URLs or Base64 Data URLs; `baoyu-image-gen` sends local refs as Data URLs
|
||||||
|
- Official docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- [MiniMax Image Generation Guide](https://platform.minimax.io/docs/guides/image-generation)
|
||||||
|
- [MiniMax Text-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-t2i)
|
||||||
|
- [MiniMax Image-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-i2i)
|
||||||
|
|
||||||
|
### OpenRouter Models
|
||||||
|
|
||||||
|
Use full OpenRouter model IDs, e.g.:
|
||||||
|
|
||||||
|
- `google/gemini-3.1-flash-image-preview` (recommended, supports image output and reference-image workflows)
|
||||||
|
- `google/gemini-2.5-flash-image-preview`
|
||||||
|
- `black-forest-labs/flux.2-pro`
|
||||||
|
- Other OpenRouter image-capable model IDs
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- OpenRouter image generation uses `/chat/completions`, not the OpenAI `/images` endpoints
|
||||||
|
- If `--ref` is used, choose a multimodal model that supports image input and image output
|
||||||
|
- `--imageSize` maps to OpenRouter `imageGenerationOptions.size`; `--size <WxH>` is converted to the nearest OpenRouter size and inferred aspect ratio when possible
|
||||||
|
|
||||||
|
### Replicate Models
|
||||||
|
|
||||||
|
Supported model formats:
|
||||||
|
|
||||||
|
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||||
|
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use Replicate default model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
|
# Override model explicitly
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider Selection
|
||||||
|
|
||||||
|
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Azure, then OpenRouter, then Replicate, then Seedream, then MiniMax (MiniMax subject reference is more specialized toward character/portrait consistency)
|
||||||
|
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`)
|
||||||
|
3. Only one API key available → use that provider
|
||||||
|
4. Multiple available → default to Google
|
||||||
|
|
||||||
|
## Quality Presets
|
||||||
|
|
||||||
|
| Preset | Google imageSize | OpenAI Size | OpenRouter size | Replicate resolution | Use Case |
|
||||||
|
|--------|------------------|-------------|-----------------|----------------------|----------|
|
||||||
|
| `normal` | 1K | 1024px | 1K | 1K | Quick previews |
|
||||||
|
| `2k` (default) | 2K | 2048px | 2K | 2K | Covers, illustrations, infographics |
|
||||||
|
|
||||||
|
**Google/OpenRouter imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
||||||
|
|
||||||
|
## Aspect Ratios
|
||||||
|
|
||||||
|
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||||
|
|
||||||
|
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||||
|
- OpenAI: maps to closest supported size
|
||||||
|
- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, aspect ratio is inferred automatically
|
||||||
|
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||||
|
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||||
|
|
||||||
|
## Generation Mode
|
||||||
|
|
||||||
|
**Default**: Sequential generation.
|
||||||
|
|
||||||
|
**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation.
|
||||||
|
|
||||||
|
| Mode | When to Use |
|
||||||
|
|------|-------------|
|
||||||
|
| Sequential (default) | Normal usage, single images, small batches |
|
||||||
|
| Parallel batch | Batch mode with 2+ tasks |
|
||||||
|
|
||||||
|
Execution choice:
|
||||||
|
|
||||||
|
| Situation | Preferred approach | Why |
|
||||||
|
|-----------|--------------------|-----|
|
||||||
|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
||||||
|
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
||||||
|
| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation |
|
||||||
|
| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path |
|
||||||
|
|
||||||
|
Rule of thumb:
|
||||||
|
|
||||||
|
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
||||||
|
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
||||||
|
|
||||||
|
Parallel behavior:
|
||||||
|
|
||||||
|
- Default worker count is automatic, capped by config, built-in default 10
|
||||||
|
- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts
|
||||||
|
- You can override worker count with `--jobs <count>`
|
||||||
|
- Each image retries automatically up to 3 attempts
|
||||||
|
- Final output includes success count, failure count, and per-image failure reasons
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Missing API key → error with setup instructions
|
||||||
|
- Generation failure → auto-retry up to 3 attempts per image
|
||||||
|
- Invalid aspect ratio → warning, proceed with default
|
||||||
|
- Reference images with unsupported provider/model → error with fix hint
|
||||||
|
|
||||||
|
## Extension Support
|
||||||
|
|
||||||
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
name: first-time-setup
|
||||||
|
description: First-time setup and default model selection flow for baoyu-image-gen
|
||||||
|
---
|
||||||
|
|
||||||
|
# First-Time Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Triggered when:
|
||||||
|
1. No EXTEND.md found → full setup (provider + model + preferences)
|
||||||
|
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
|
||||||
|
|
||||||
|
## Setup Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
No EXTEND.md found EXTEND.md found, model null
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ AskUserQuestion │ │ AskUserQuestion │
|
||||||
|
│ (full setup) │ │ (model only) │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Create EXTEND.md │ │ Update EXTEND.md │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Continue Continue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow 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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Provider"
|
||||||
|
question: "Default image generation provider?"
|
||||||
|
options:
|
||||||
|
- label: "Google (Recommended)"
|
||||||
|
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
||||||
|
- label: "OpenAI"
|
||||||
|
description: "GPT Image - consistent quality, reliable output"
|
||||||
|
- label: "Azure OpenAI"
|
||||||
|
description: "Azure-hosted GPT Image deployments with resource-specific routing"
|
||||||
|
- label: "OpenRouter"
|
||||||
|
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||||
|
- label: "DashScope"
|
||||||
|
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||||
|
- label: "MiniMax"
|
||||||
|
description: "MiniMax image generation with subject-reference character workflows"
|
||||||
|
- label: "Replicate"
|
||||||
|
description: "Community models - nano-banana-pro, flexible model selection"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 2: Default Google Model
|
||||||
|
|
||||||
|
Only show if user selected Google or auto-detect (no explicit provider).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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 2b: Default OpenRouter Model
|
||||||
|
|
||||||
|
Only show if user selected OpenRouter.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "OpenRouter Model"
|
||||||
|
question: "Default OpenRouter image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
|
||||||
|
description: "Best general-purpose OpenRouter image model with reference-image workflows"
|
||||||
|
- label: "google/gemini-2.5-flash-image-preview"
|
||||||
|
description: "Fast Gemini preview model on OpenRouter"
|
||||||
|
- label: "black-forest-labs/flux.2-pro"
|
||||||
|
description: "Strong text-to-image quality through OpenRouter"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 2c: Default Azure Deployment
|
||||||
|
|
||||||
|
Only show if user selected Azure OpenAI.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Azure Deploy"
|
||||||
|
question: "Default Azure image deployment name?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Best default if your Azure deployment uses the same name"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
description: "Previous GPT Image deployment name"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 2d: Default MiniMax Model
|
||||||
|
|
||||||
|
Only show if user selected MiniMax.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "MiniMax Model"
|
||||||
|
question: "Default MiniMax image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "image-01 (Recommended)"
|
||||||
|
description: "Best default, supports aspect ratios and custom width/height"
|
||||||
|
- label: "image-01-live"
|
||||||
|
description: "Faster variant, use aspect ratio instead of custom size"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 3: Default Quality
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Save"
|
||||||
|
question: "Where to save preferences?"
|
||||||
|
options:
|
||||||
|
- label: "Project (Recommended)"
|
||||||
|
description: ".baoyu-skills/ (this project only)"
|
||||||
|
- label: "User"
|
||||||
|
description: "~/.baoyu-skills/ (all projects)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Locations
|
||||||
|
|
||||||
|
| Choice | Path | Scope |
|
||||||
|
|--------|------|-------|
|
||||||
|
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
|
||||||
|
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
|
||||||
|
|
||||||
|
### EXTEND.md Template
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
default_provider: [selected provider or null]
|
||||||
|
default_quality: [selected quality]
|
||||||
|
default_aspect_ratio: null
|
||||||
|
default_image_size: null
|
||||||
|
default_model:
|
||||||
|
google: [selected google model or null]
|
||||||
|
openai: null
|
||||||
|
azure: [selected azure deployment or null]
|
||||||
|
openrouter: [selected openrouter model or null]
|
||||||
|
dashscope: null
|
||||||
|
minimax: [selected minimax model or null]
|
||||||
|
replicate: 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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "OpenAI Model"
|
||||||
|
question: "Choose a default OpenAI image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Latest GPT Image model, high quality"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
description: "Previous generation GPT Image model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Azure Deployment Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Azure Deploy"
|
||||||
|
question: "Choose a default Azure image deployment name?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Use when your Azure deployment name matches the GPT-image-1.5 model"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
description: "Use when your Azure deployment name matches GPT-image-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for Azure setup:
|
||||||
|
|
||||||
|
- In `baoyu-image-gen`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family.
|
||||||
|
- If the deployment name is custom, save that exact deployment name in `default_model.azure`.
|
||||||
|
|
||||||
|
### OpenRouter Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "OpenRouter Model"
|
||||||
|
question: "Choose a default OpenRouter image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
|
||||||
|
description: "Recommended for image output and reference-image edits"
|
||||||
|
- label: "google/gemini-2.5-flash-image-preview"
|
||||||
|
description: "Fast preview-oriented image generation"
|
||||||
|
- label: "black-forest-labs/flux.2-pro"
|
||||||
|
description: "High-quality text-to-image through OpenRouter"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DashScope Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "DashScope Model"
|
||||||
|
question: "Choose a default DashScope image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "qwen-image-2.0-pro (Recommended)"
|
||||||
|
description: "Best DashScope model for text rendering and custom sizes"
|
||||||
|
- label: "qwen-image-2.0"
|
||||||
|
description: "Faster 2.0 variant with flexible output size"
|
||||||
|
- label: "qwen-image-max"
|
||||||
|
description: "Legacy Qwen model with five fixed output sizes"
|
||||||
|
- label: "qwen-image-plus"
|
||||||
|
description: "Legacy Qwen model, same current capability as qwen-image"
|
||||||
|
- label: "z-image-turbo"
|
||||||
|
description: "Legacy DashScope model for compatibility"
|
||||||
|
- label: "z-image-ultra"
|
||||||
|
description: "Legacy DashScope model, higher quality but slower"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for DashScope setup:
|
||||||
|
|
||||||
|
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
||||||
|
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||||
|
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||||
|
|
||||||
|
### Replicate Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Replicate Model"
|
||||||
|
question: "Choose a default Replicate image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "google/nano-banana-pro (Recommended)"
|
||||||
|
description: "Google's fast image model on Replicate"
|
||||||
|
- label: "google/nano-banana"
|
||||||
|
description: "Google's base image model on Replicate"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MiniMax Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "MiniMax Model"
|
||||||
|
question: "Choose a default MiniMax image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "image-01 (Recommended)"
|
||||||
|
description: "Best general-purpose MiniMax image model with custom width/height support"
|
||||||
|
- label: "image-01-live"
|
||||||
|
description: "Lower-latency MiniMax image model using aspect ratios"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for MiniMax setup:
|
||||||
|
|
||||||
|
- `image-01` is the safest default. It supports official `aspect_ratio` values and documented custom `width` / `height` output sizes.
|
||||||
|
- `image-01-live` is useful when the user prefers faster generation and can work with aspect-ratio-based sizing.
|
||||||
|
- MiniMax subject reference currently uses `subject_reference[].type = character`; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default_model:
|
||||||
|
google: [value or null]
|
||||||
|
openai: [value or null]
|
||||||
|
azure: [value or null]
|
||||||
|
openrouter: [value or null]
|
||||||
|
dashscope: [value or null]
|
||||||
|
minimax: [value or null]
|
||||||
|
replicate: [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
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
name: preferences-schema
|
||||||
|
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||||
|
---
|
||||||
|
|
||||||
|
# Preferences Schema
|
||||||
|
|
||||||
|
## Full Schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|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/OpenRouter, overrides quality)
|
||||||
|
|
||||||
|
default_model:
|
||||||
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
|
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||||
|
azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod"
|
||||||
|
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||||
|
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||||
|
minimax: null # e.g., "image-01"
|
||||||
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
|
google:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
openai:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
azure:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
openrouter:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
dashscope:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `version` | int | 1 | Schema version |
|
||||||
|
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
|
||||||
|
| `default_quality` | string\|null | null | Default quality (null = 2k) |
|
||||||
|
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
|
||||||
|
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
||||||
|
| `default_model.google` | string\|null | null | Google default model |
|
||||||
|
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||||
|
| `default_model.azure` | string\|null | null | Azure default deployment name |
|
||||||
|
| `default_model.openrouter` | string\|null | null | OpenRouter default model |
|
||||||
|
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||||
|
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
||||||
|
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||||
|
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||||
|
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||||
|
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**Minimal**:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
default_provider: google
|
||||||
|
default_quality: 2k
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full**:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
default_provider: google
|
||||||
|
default_quality: 2k
|
||||||
|
default_aspect_ratio: "16:9"
|
||||||
|
default_image_size: 2K
|
||||||
|
default_model:
|
||||||
|
google: "gemini-3-pro-image-preview"
|
||||||
|
openai: "gpt-image-1.5"
|
||||||
|
azure: "gpt-image-1.5"
|
||||||
|
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||||
|
dashscope: "qwen-image-2.0-pro"
|
||||||
|
minimax: "image-01"
|
||||||
|
replicate: "google/nano-banana-pro"
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
|
azure:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
openrouter:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
---
|
||||||
|
```
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs, ExtendConfig } from "./types.ts";
|
||||||
|
import {
|
||||||
|
createTaskArgs,
|
||||||
|
detectProvider,
|
||||||
|
getConfiguredMaxWorkers,
|
||||||
|
getConfiguredProviderRateLimits,
|
||||||
|
getWorkerCount,
|
||||||
|
isRetryableGenerationError,
|
||||||
|
loadBatchTasks,
|
||||||
|
mergeConfig,
|
||||||
|
normalizeOutputImagePath,
|
||||||
|
parseArgs,
|
||||||
|
parseSimpleYaml,
|
||||||
|
} from "./main.ts";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
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 makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parseArgs parses the main image-gen CLI flags", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"--promptfiles",
|
||||||
|
"prompts/system.md",
|
||||||
|
"prompts/content.md",
|
||||||
|
"--image",
|
||||||
|
"out/hero",
|
||||||
|
"--provider",
|
||||||
|
"openai",
|
||||||
|
"--quality",
|
||||||
|
"2k",
|
||||||
|
"--imageSize",
|
||||||
|
"4k",
|
||||||
|
"--ref",
|
||||||
|
"ref/one.png",
|
||||||
|
"ref/two.jpg",
|
||||||
|
"--n",
|
||||||
|
"3",
|
||||||
|
"--jobs",
|
||||||
|
"5",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
|
||||||
|
assert.equal(args.imagePath, "out/hero");
|
||||||
|
assert.equal(args.provider, "openai");
|
||||||
|
assert.equal(args.quality, "2k");
|
||||||
|
assert.equal(args.imageSize, "4K");
|
||||||
|
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||||
|
assert.equal(args.n, 3);
|
||||||
|
assert.equal(args.jobs, 5);
|
||||||
|
assert.equal(args.json, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs falls back to positional prompt and rejects invalid provider", () => {
|
||||||
|
const positional = parseArgs(["draw", "a", "cat"]);
|
||||||
|
assert.equal(positional.prompt, "draw a cat");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => parseArgs(["--provider", "stability"]),
|
||||||
|
/Invalid provider/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseSimpleYaml parses nested defaults and provider limits", () => {
|
||||||
|
const yaml = `
|
||||||
|
version: 2
|
||||||
|
default_provider: openrouter
|
||||||
|
default_quality: normal
|
||||||
|
default_aspect_ratio: '16:9'
|
||||||
|
default_image_size: 2K
|
||||||
|
default_model:
|
||||||
|
google: gemini-3-pro-image-preview
|
||||||
|
openai: gpt-image-1.5
|
||||||
|
azure: image-prod
|
||||||
|
minimax: image-01
|
||||||
|
batch:
|
||||||
|
max_workers: 8
|
||||||
|
provider_limits:
|
||||||
|
google:
|
||||||
|
concurrency: 2
|
||||||
|
start_interval_ms: 900
|
||||||
|
openai:
|
||||||
|
concurrency: 4
|
||||||
|
minimax:
|
||||||
|
concurrency: 2
|
||||||
|
start_interval_ms: 1400
|
||||||
|
azure:
|
||||||
|
concurrency: 1
|
||||||
|
start_interval_ms: 1500
|
||||||
|
`;
|
||||||
|
|
||||||
|
const config = parseSimpleYaml(yaml);
|
||||||
|
|
||||||
|
assert.equal(config.version, 2);
|
||||||
|
assert.equal(config.default_provider, "openrouter");
|
||||||
|
assert.equal(config.default_quality, "normal");
|
||||||
|
assert.equal(config.default_aspect_ratio, "16:9");
|
||||||
|
assert.equal(config.default_image_size, "2K");
|
||||||
|
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||||
|
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||||
|
assert.equal(config.default_model?.azure, "image-prod");
|
||||||
|
assert.equal(config.default_model?.minimax, "image-01");
|
||||||
|
assert.equal(config.batch?.max_workers, 8);
|
||||||
|
assert.deepEqual(config.batch?.provider_limits?.google, {
|
||||||
|
concurrency: 2,
|
||||||
|
start_interval_ms: 900,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||||
|
concurrency: 4,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.batch?.provider_limits?.minimax, {
|
||||||
|
concurrency: 2,
|
||||||
|
start_interval_ms: 1400,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.batch?.provider_limits?.azure, {
|
||||||
|
concurrency: 1,
|
||||||
|
start_interval_ms: 1500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeConfig only fills values missing from CLI args", () => {
|
||||||
|
const merged = mergeConfig(
|
||||||
|
makeArgs({
|
||||||
|
provider: "openai",
|
||||||
|
quality: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
imageSize: "4K",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
default_provider: "google",
|
||||||
|
default_quality: "2k",
|
||||||
|
default_aspect_ratio: "3:2",
|
||||||
|
default_image_size: "2K",
|
||||||
|
} satisfies Partial<ExtendConfig>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(merged.provider, "openai");
|
||||||
|
assert.equal(merged.quality, "2k");
|
||||||
|
assert.equal(merged.aspectRatio, "3:2");
|
||||||
|
assert.equal(merged.imageSize, "4K");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
detectProvider(
|
||||||
|
makeArgs({
|
||||||
|
provider: "dashscope",
|
||||||
|
referenceImages: ["ref.png"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/Reference images require a ref-capable provider/,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: "google-key",
|
||||||
|
OPENAI_API_KEY: "openai-key",
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
assert.equal(detectProvider(makeArgs()), "google");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: "openai-key",
|
||||||
|
AZURE_OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_BASE_URL: null,
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||||
|
"openai",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(detectProvider(makeArgs()), "azure");
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||||
|
"azure",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: "ark-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(
|
||||||
|
makeArgs({
|
||||||
|
model: "doubao-seedream-4-5-251128",
|
||||||
|
referenceImages: ["ref.png"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"seedream",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(
|
||||||
|
makeArgs({
|
||||||
|
provider: "seedream",
|
||||||
|
referenceImages: ["ref.png"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"seedream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_BASE_URL: null,
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: "minimax-key",
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(detectProvider(makeArgs()), "minimax");
|
||||||
|
assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax");
|
||||||
|
assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||||
|
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
|
||||||
|
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||||
|
});
|
||||||
|
|
||||||
|
const extendConfig: Partial<ExtendConfig> = {
|
||||||
|
batch: {
|
||||||
|
max_workers: 7,
|
||||||
|
provider_limits: {
|
||||||
|
google: {
|
||||||
|
concurrency: 2,
|
||||||
|
start_interval_ms: 900,
|
||||||
|
},
|
||||||
|
minimax: {
|
||||||
|
concurrency: 1,
|
||||||
|
start_interval_ms: 1500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getConfiguredMaxWorkers(extendConfig), 12);
|
||||||
|
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, {
|
||||||
|
concurrency: 5,
|
||||||
|
startIntervalMs: 450,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
|
||||||
|
concurrency: 1,
|
||||||
|
startIntervalMs: 1500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-image-gen-batch-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const batchFile = path.join(root, "jobs", "batch.json");
|
||||||
|
await fs.mkdir(path.dirname(batchFile), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
batchFile,
|
||||||
|
JSON.stringify({
|
||||||
|
jobs: 2,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: "hero",
|
||||||
|
promptFiles: ["prompts/hero.md"],
|
||||||
|
image: "out/hero",
|
||||||
|
ref: ["refs/hero.png"],
|
||||||
|
ar: "16:9",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = await loadBatchTasks(batchFile);
|
||||||
|
assert.equal(loaded.jobs, 2);
|
||||||
|
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
||||||
|
assert.equal(loaded.tasks[0]?.id, "hero");
|
||||||
|
|
||||||
|
const taskArgs = createTaskArgs(
|
||||||
|
makeArgs({
|
||||||
|
provider: "replicate",
|
||||||
|
quality: "2k",
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
loaded.tasks[0]!,
|
||||||
|
loaded.batchDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(taskArgs.promptFiles, [
|
||||||
|
path.join(loaded.batchDir, "prompts/hero.md"),
|
||||||
|
]);
|
||||||
|
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
|
||||||
|
assert.deepEqual(taskArgs.referenceImages, [
|
||||||
|
path.join(loaded.batchDir, "refs/hero.png"),
|
||||||
|
]);
|
||||||
|
assert.equal(taskArgs.provider, "replicate");
|
||||||
|
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||||
|
assert.equal(taskArgs.quality, "2k");
|
||||||
|
assert.equal(taskArgs.json, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||||
|
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||||
|
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
|
||||||
|
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||||
|
|
||||||
|
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||||
|
assert.equal(getWorkerCount(2, 6, 5), 2);
|
||||||
|
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||||
|
|
||||||
|
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||||
|
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
generateImage,
|
||||||
|
getDefaultModel,
|
||||||
|
parseAzureBaseURL,
|
||||||
|
validateArgs,
|
||||||
|
} from "./azure.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Azure endpoint parsing and default deployment selection follow env precedence", (t) => {
|
||||||
|
assert.deepEqual(parseAzureBaseURL("https://example.openai.azure.com"), {
|
||||||
|
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||||
|
deployment: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseAzureBaseURL("https://example.openai.azure.com/openai/deployments/from-url"),
|
||||||
|
{
|
||||||
|
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||||
|
deployment: "from-url",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/from-url",
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: "explicit-deploy",
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: "env-fallback",
|
||||||
|
});
|
||||||
|
assert.equal(getDefaultModel(), "explicit-deploy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure validateArgs rejects unsupported edit input formats before the API call", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.png", "photo.jpeg"] })),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.webp"] })),
|
||||||
|
/PNG or JPG\/JPEG/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure image generation routes model to deployment and sends mapped quality", async (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/default-deploy",
|
||||||
|
AZURE_API_VERSION: null,
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: null,
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; body: string }> = [];
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
url: String(input),
|
||||||
|
body: String(init?.body ?? ""),
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
data: [{ b64_json: Buffer.from("azure-image").toString("base64") }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytes = await generateImage(
|
||||||
|
"A calm lake at sunset",
|
||||||
|
"custom-deploy",
|
||||||
|
makeArgs({ quality: "normal" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "azure-image");
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.url,
|
||||||
|
"https://example.openai.azure.com/openai/deployments/custom-deploy/images/generations?api-version=2025-04-01-preview",
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0]!.body) as Record<string, string>;
|
||||||
|
assert.equal(body.quality, "medium");
|
||||||
|
assert.equal(body.size, "1024x1024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure image edits include quality in multipart requests", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-image-gen-azure-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const pngPath = path.join(root, "ref.png");
|
||||||
|
const jpgPath = path.join(root, "ref.jpg");
|
||||||
|
await fs.writeFile(pngPath, "png-bytes");
|
||||||
|
await fs.writeFile(jpgPath, "jpg-bytes");
|
||||||
|
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||||
|
AZURE_API_VERSION: "2025-04-01-preview",
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: null,
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; form: FormData }> = [];
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
url: String(input),
|
||||||
|
form: init?.body as FormData,
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
data: [{ b64_json: Buffer.from("edited-image").toString("base64") }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytes = await generateImage(
|
||||||
|
"Add warm lighting",
|
||||||
|
"edit-deploy",
|
||||||
|
makeArgs({
|
||||||
|
quality: "2k",
|
||||||
|
referenceImages: [pngPath, jpgPath],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image");
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.url,
|
||||||
|
"https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview",
|
||||||
|
);
|
||||||
|
assert.equal(calls[0]?.form.get("quality"), "high");
|
||||||
|
assert.equal(calls[0]?.form.get("size"), "1024x1024");
|
||||||
|
assert.equal(calls[0]?.form.getAll("image[]").length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
import { getOpenAISize, extractImageFromResponse } from "./openai.ts";
|
||||||
|
|
||||||
|
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||||
|
type AzureEndpoint = {
|
||||||
|
resourceBaseURL: string;
|
||||||
|
deployment: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview";
|
||||||
|
const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
|
||||||
|
|
||||||
|
export function parseAzureBaseURL(url: string): AzureEndpoint {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const trimmedPath = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/);
|
||||||
|
|
||||||
|
if (deploymentMatch) {
|
||||||
|
parsed.pathname = `${deploymentMatch[1] || ""}/openai`;
|
||||||
|
return {
|
||||||
|
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||||
|
deployment: decodeURIComponent(deploymentMatch[2]!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`;
|
||||||
|
return {
|
||||||
|
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||||
|
deployment: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim();
|
||||||
|
if (explicitDeployment) return explicitDeployment;
|
||||||
|
|
||||||
|
const baseURL = process.env.AZURE_OPENAI_BASE_URL;
|
||||||
|
if (baseURL) {
|
||||||
|
try {
|
||||||
|
const { deployment } = parseAzureBaseURL(baseURL);
|
||||||
|
if (deployment) return deployment;
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid URLs here so the required-env check can raise the user-facing error later.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEndpoint(): AzureEndpoint {
|
||||||
|
const url = process.env.AZURE_OPENAI_BASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseAzureBaseURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string {
|
||||||
|
const key = process.env.AZURE_OPENAI_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
"AZURE_OPENAI_API_KEY is required. Get it from Azure Portal → your OpenAI resource → Keys and Endpoint."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiVersion(): string {
|
||||||
|
return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeployment(model: string): string {
|
||||||
|
const deployment = model.trim();
|
||||||
|
if (!deployment) {
|
||||||
|
throw new Error(
|
||||||
|
"Azure deployment name is required. Use --model <deployment>, AZURE_OPENAI_DEPLOYMENT, AZURE_OPENAI_IMAGE_MODEL, or embed the deployment in AZURE_OPENAI_BASE_URL."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildURL(deployment: string, pathSuffix: string): string {
|
||||||
|
const { resourceBaseURL } = getEndpoint();
|
||||||
|
return `${resourceBaseURL}/deployments/${encodeURIComponent(deployment)}${pathSuffix}?api-version=${getApiVersion()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
return { "api-key": getApiKey() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAzureQuality(quality: CliArgs["quality"]): "medium" | "high" {
|
||||||
|
return quality === "2k" ? "high" : "medium";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(_model: string, args: CliArgs): void {
|
||||||
|
for (const refPath of args.referenceImages) {
|
||||||
|
const ext = path.extname(refPath).toLowerCase();
|
||||||
|
if (!AZURE_EDIT_IMAGE_EXTENSIONS.has(ext)) {
|
||||||
|
throw new Error(
|
||||||
|
`Azure OpenAI reference images must be PNG or JPG/JPEG. Unsupported file: ${refPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const deployment = getDeployment(model);
|
||||||
|
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
return generateWithAzureEdits(prompt, deployment, size, args.referenceImages, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateWithAzureGenerations(prompt, deployment, size, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithAzureGenerations(
|
||||||
|
prompt: string,
|
||||||
|
deployment: string,
|
||||||
|
size: string,
|
||||||
|
quality: CliArgs["quality"]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
prompt,
|
||||||
|
size,
|
||||||
|
n: 1,
|
||||||
|
quality: getAzureQuality(quality),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(buildURL(deployment, "/images/generations"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Azure OpenAI API error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as OpenAIImageResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithAzureEdits(
|
||||||
|
prompt: string,
|
||||||
|
deployment: string,
|
||||||
|
size: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
quality: CliArgs["quality"]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("prompt", prompt);
|
||||||
|
form.append("size", size);
|
||||||
|
form.append("n", "1");
|
||||||
|
form.append("quality", getAzureQuality(quality));
|
||||||
|
|
||||||
|
for (const refPath of referenceImages) {
|
||||||
|
const bytes = await readFile(refPath);
|
||||||
|
const filename = path.basename(refPath);
|
||||||
|
const mimeType = path.extname(filename).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
form.append("image[]", blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(buildURL(deployment, "/images/edits"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Azure OpenAI edits API error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as OpenAIImageResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDefaultModel,
|
||||||
|
getModelFamily,
|
||||||
|
getQwen2SizeFromAspectRatio,
|
||||||
|
getSizeFromAspectRatio,
|
||||||
|
normalizeSize,
|
||||||
|
parseAspectRatio,
|
||||||
|
parseSize,
|
||||||
|
resolveSizeForModel,
|
||||||
|
} from "./dashscope.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
|
||||||
|
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
|
||||||
|
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
|
||||||
|
|
||||||
|
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
|
||||||
|
assert.equal(getDefaultModel(), "qwen-image-max");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||||
|
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||||
|
assert.equal(parseAspectRatio("square"), null);
|
||||||
|
assert.equal(parseAspectRatio("-1:2"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
|
||||||
|
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
||||||
|
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
||||||
|
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
||||||
|
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
|
||||||
|
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||||
|
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||||
|
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
||||||
|
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||||
|
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
|
||||||
|
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
|
||||||
|
const parsed = parseSize(size);
|
||||||
|
assert.ok(parsed);
|
||||||
|
assert.ok(parsed.width * parsed.height >= 512 * 512);
|
||||||
|
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||||
|
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||||
|
size: "2048x872",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
"2048*872",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||||
|
size: "4096x4096",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/total pixels between/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image-max", {
|
||||||
|
size: null,
|
||||||
|
aspectRatio: "1:1",
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
"1328*1328",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveSizeForModel("qwen-image", {
|
||||||
|
size: "1664x928",
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
quality: "normal",
|
||||||
|
}),
|
||||||
|
"1664*928",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-max", {
|
||||||
|
size: null,
|
||||||
|
aspectRatio: "21:9",
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/supports only fixed ratios/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSizeForModel("qwen-image-plus", {
|
||||||
|
size: "1024x1024",
|
||||||
|
aspectRatio: null,
|
||||||
|
quality: "2k",
|
||||||
|
}),
|
||||||
|
/support only these sizes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DashScope size normalization converts WxH into provider format", () => {
|
||||||
|
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||||
|
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||||
|
});
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import type { CliArgs, Quality } from "../types";
|
||||||
|
|
||||||
|
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
|
||||||
|
|
||||||
|
type DashScopeModelSpec = {
|
||||||
|
family: DashScopeModelFamily;
|
||||||
|
defaultSize: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "qwen-image-2.0-pro";
|
||||||
|
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
|
||||||
|
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
|
||||||
|
const SIZE_STEP = 16;
|
||||||
|
const QWEN_NEGATIVE_PROMPT =
|
||||||
|
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
|
||||||
|
|
||||||
|
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
|
||||||
|
normal: 1024 * 1024,
|
||||||
|
"2k": 1536 * 1536,
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
|
||||||
|
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
|
||||||
|
"2:3": { normal: "768*1152", "2k": "1024*1536" },
|
||||||
|
"3:2": { normal: "1152*768", "2k": "1536*1024" },
|
||||||
|
"3:4": { normal: "960*1280", "2k": "1080*1440" },
|
||||||
|
"4:3": { normal: "1280*960", "2k": "1440*1080" },
|
||||||
|
"9:16": { normal: "720*1280", "2k": "1080*1920" },
|
||||||
|
"16:9": { normal: "1280*720", "2k": "1920*1080" },
|
||||||
|
"21:9": { normal: "1344*576", "2k": "2048*872" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
|
||||||
|
"16:9": "1664*928",
|
||||||
|
"4:3": "1472*1104",
|
||||||
|
"1:1": "1328*1328",
|
||||||
|
"3:4": "1104*1472",
|
||||||
|
"9:16": "928*1664",
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
|
||||||
|
|
||||||
|
const LEGACY_STANDARD_SIZES: [number, number][] = [
|
||||||
|
[1024, 1024],
|
||||||
|
[1280, 720],
|
||||||
|
[720, 1280],
|
||||||
|
[1024, 768],
|
||||||
|
[768, 1024],
|
||||||
|
[1536, 1024],
|
||||||
|
[1024, 1536],
|
||||||
|
[1536, 864],
|
||||||
|
[864, 1536],
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
|
||||||
|
[1536, 1536],
|
||||||
|
[2048, 1152],
|
||||||
|
[1152, 2048],
|
||||||
|
[1536, 1024],
|
||||||
|
[1024, 1536],
|
||||||
|
[1536, 864],
|
||||||
|
[864, 1536],
|
||||||
|
[2048, 2048],
|
||||||
|
];
|
||||||
|
|
||||||
|
const QWEN_2_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "qwen2",
|
||||||
|
defaultSize: "1024*1024",
|
||||||
|
};
|
||||||
|
|
||||||
|
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "qwenFixed",
|
||||||
|
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_SPEC: DashScopeModelSpec = {
|
||||||
|
family: "legacy",
|
||||||
|
defaultSize: "1536*1536",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
|
||||||
|
"qwen-image-2.0-pro": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0": QWEN_2_SPEC,
|
||||||
|
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
|
||||||
|
"qwen-image-max": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-plus": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
|
||||||
|
"qwen-image": QWEN_FIXED_SPEC,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getModelSpec(model: string): DashScopeModelSpec {
|
||||||
|
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelFamily(model: string): DashScopeModelFamily {
|
||||||
|
return getModelSpec(model).family;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuality(quality: CliArgs["quality"]): Quality {
|
||||||
|
return quality === "normal" ? "normal" : "2k";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||||
|
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const w = parseFloat(match[1]!);
|
||||||
|
const h = parseFloat(match[2]!);
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSize(size: string): string {
|
||||||
|
return size.replace("x", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSize(size: string): { width: number; height: number } | null {
|
||||||
|
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(width: number, height: number): string {
|
||||||
|
return `${width}*${height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatioValue(ar: string): number | null {
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return parsed.width / parsed.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
|
||||||
|
const targetRatio = getRatioValue(ar);
|
||||||
|
if (targetRatio == null) return null;
|
||||||
|
|
||||||
|
let bestKey: string | null = null;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const candidateRatio = getRatioValue(candidate);
|
||||||
|
if (candidateRatio == null) continue;
|
||||||
|
const diff = Math.abs(candidateRatio - targetRatio);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
bestKey = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestDiff <= tolerance ? bestKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToStep(value: number): number {
|
||||||
|
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToPixelBudget(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minPixels: number,
|
||||||
|
maxPixels: number,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
let nextWidth = width;
|
||||||
|
let nextHeight = height;
|
||||||
|
let pixels = nextWidth * nextHeight;
|
||||||
|
|
||||||
|
if (pixels > maxPixels) {
|
||||||
|
const scale = Math.sqrt(maxPixels / pixels);
|
||||||
|
nextWidth *= scale;
|
||||||
|
nextHeight *= scale;
|
||||||
|
} else if (pixels < minPixels) {
|
||||||
|
const scale = Math.sqrt(minPixels / pixels);
|
||||||
|
nextWidth *= scale;
|
||||||
|
nextHeight *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roundedWidth = roundToStep(nextWidth);
|
||||||
|
let roundedHeight = roundToStep(nextHeight);
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
|
||||||
|
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
|
||||||
|
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
|
||||||
|
roundedWidth -= SIZE_STEP;
|
||||||
|
} else if (roundedHeight > SIZE_STEP) {
|
||||||
|
roundedHeight -= SIZE_STEP;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pixels < minPixels) {
|
||||||
|
if (roundedWidth <= roundedHeight) {
|
||||||
|
roundedWidth += SIZE_STEP;
|
||||||
|
} else {
|
||||||
|
roundedHeight += SIZE_STEP;
|
||||||
|
}
|
||||||
|
pixels = roundedWidth * roundedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: roundedWidth, height: roundedHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
|
const normalizedQuality = normalizeQuality(quality);
|
||||||
|
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
|
||||||
|
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
|
||||||
|
|
||||||
|
if (!ar) return defaultSize;
|
||||||
|
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) return defaultSize;
|
||||||
|
|
||||||
|
const targetRatio = parsed.width / parsed.height;
|
||||||
|
let best = defaultSize;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const [width, height] of sizes) {
|
||||||
|
const diff = Math.abs(width / height - targetRatio);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
best = formatSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
|
const normalizedQuality = normalizeQuality(quality);
|
||||||
|
|
||||||
|
if (!ar) {
|
||||||
|
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
|
||||||
|
if (recommendedRatio) {
|
||||||
|
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) {
|
||||||
|
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRatio = parsed.width / parsed.height;
|
||||||
|
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
|
||||||
|
const rawWidth = Math.sqrt(targetPixels * targetRatio);
|
||||||
|
const rawHeight = Math.sqrt(targetPixels / targetRatio);
|
||||||
|
const fitted = fitToPixelBudget(
|
||||||
|
rawWidth,
|
||||||
|
rawHeight,
|
||||||
|
MIN_QWEN_2_TOTAL_PIXELS,
|
||||||
|
MAX_QWEN_2_TOTAL_PIXELS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return formatSize(fitted.width, fitted.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||||
|
if (quality === "normal") {
|
||||||
|
console.warn(
|
||||||
|
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
|
||||||
|
|
||||||
|
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
|
||||||
|
if (!ratioKey) {
|
||||||
|
throw new Error(
|
||||||
|
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
|
||||||
|
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSizeFormat(size: string): { width: number; height: number } {
|
||||||
|
const parsed = parseSize(size);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQwen2Size(size: string): string {
|
||||||
|
const normalized = normalizeSize(size);
|
||||||
|
const parsed = validateSizeFormat(normalized);
|
||||||
|
const totalPixels = parsed.width * parsed.height;
|
||||||
|
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
|
||||||
|
throw new Error(
|
||||||
|
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
|
||||||
|
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQwenFixedSize(size: string): string {
|
||||||
|
const normalized = normalizeSize(size);
|
||||||
|
validateSizeFormat(normalized);
|
||||||
|
if (!QWEN_FIXED_SIZES.includes(normalized)) {
|
||||||
|
throw new Error(
|
||||||
|
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
|
||||||
|
`Received ${normalized}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSizeForModel(
|
||||||
|
model: string,
|
||||||
|
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||||
|
): string {
|
||||||
|
const spec = getModelSpec(model);
|
||||||
|
|
||||||
|
if (args.size) {
|
||||||
|
if (spec.family === "qwen2") return validateQwen2Size(args.size);
|
||||||
|
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
|
||||||
|
validateSizeFormat(args.size);
|
||||||
|
return normalizeSize(args.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.family === "qwen2") {
|
||||||
|
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.family === "qwenFixed") {
|
||||||
|
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParameters(
|
||||||
|
family: DashScopeModelFamily,
|
||||||
|
size: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parameters: Record<string, unknown> = {
|
||||||
|
prompt_extend: false,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (family === "qwen2" || family === "qwenFixed") {
|
||||||
|
parameters.watermark = false;
|
||||||
|
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashScopeResponse = {
|
||||||
|
output?: {
|
||||||
|
result_image?: string;
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: Array<{ image?: string }>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
|
||||||
|
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("Response:", JSON.stringify(result, null, 2));
|
||||||
|
throw new Error("No image in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
|
||||||
|
const imgRes = await fetch(imageData);
|
||||||
|
if (!imgRes.ok) throw new Error("Failed to download image");
|
||||||
|
const buf = await imgRes.arrayBuffer();
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = getModelSpec(model);
|
||||||
|
const size = resolveSizeForModel(model, args);
|
||||||
|
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
input: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ text: prompt }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parameters: buildParameters(spec.family, size),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, 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 error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json() as DashScopeResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
addAspectRatioToPrompt,
|
||||||
|
buildGoogleUrl,
|
||||||
|
buildPromptWithAspect,
|
||||||
|
extractInlineImageData,
|
||||||
|
extractPredictedImageData,
|
||||||
|
getGoogleImageSize,
|
||||||
|
isGoogleImagen,
|
||||||
|
isGoogleMultimodal,
|
||||||
|
normalizeGoogleModelId,
|
||||||
|
} from "./google.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Google provider helpers normalize model IDs and select image size defaults", () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
|
||||||
|
"gemini-3.1-flash-image-preview",
|
||||||
|
);
|
||||||
|
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
||||||
|
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
||||||
|
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
|
||||||
|
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
|
||||||
|
useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" });
|
||||||
|
assert.equal(
|
||||||
|
buildGoogleUrl("models/demo:generateContent"),
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => {
|
||||||
|
useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" });
|
||||||
|
assert.equal(
|
||||||
|
buildGoogleUrl("/models/demo:predict"),
|
||||||
|
"https://example.com/custom/v1beta/models/demo:predict",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
addAspectRatioToPrompt("A city skyline", "16:9"),
|
||||||
|
"A city skyline Aspect ratio: 16:9.",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildPromptWithAspect("A city skyline", "16:9", "2k"),
|
||||||
|
"A city skyline Aspect ratio: 16:9. High resolution 2048px.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Google response extractors find inline and predicted image payloads", () => {
|
||||||
|
assert.equal(
|
||||||
|
extractInlineImageData({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ inlineData: { data: "inline-base64" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"inline-base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
extractPredictedImageData({
|
||||||
|
predictions: [{ image: { imageBytes: "predicted-base64" } }],
|
||||||
|
}),
|
||||||
|
"predicted-base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
extractPredictedImageData({
|
||||||
|
generatedImages: [{ bytesBase64Encoded: "generated-base64" }],
|
||||||
|
}),
|
||||||
|
"generated-base64",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { execFileSync } 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGoogleModelId(model: string): string {
|
||||||
|
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGoogleMultimodal(model: string): boolean {
|
||||||
|
const normalized = normalizeGoogleModelId(model);
|
||||||
|
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 args = [
|
||||||
|
"-s",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
...(proxy ? ["-x", proxy] : []),
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"-H",
|
||||||
|
`x-goog-api-key: ${apiKey}`,
|
||||||
|
"-d",
|
||||||
|
"@-",
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
try {
|
||||||
|
result = execFileSync("curl", args, {
|
||||||
|
input: bodyStr,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 100 * 1024 * 1024,
|
||||||
|
timeout: 310000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { message?: string; stderr?: string | Buffer };
|
||||||
|
const stderrText =
|
||||||
|
typeof e.stderr === "string"
|
||||||
|
? e.stderr
|
||||||
|
: e.stderr
|
||||||
|
? e.stderr.toString("utf8")
|
||||||
|
: "";
|
||||||
|
const details = stderrText.trim() || e.message || "curl request failed";
|
||||||
|
throw new Error(`Google API request failed via curl: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result) as any;
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Google API error (${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 error (${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 or GEMINI_API_KEY is required");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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("Generating image with 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("Generation completed.");
|
||||||
|
|
||||||
|
const imageData = extractInlineImageData(response);
|
||||||
|
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||||
|
|
||||||
|
throw new Error("No image in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("No image in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (isGoogleImagen(model)) {
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return generateWithImagen(prompt, model, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateWithGemini(prompt, model, args);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import { generateImage } from "./jimeng.ts";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Jimeng submit request uses prompt field expected by current API", async (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
JIMENG_ACCESS_KEY_ID: "test-access-key",
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: "test-secret-key",
|
||||||
|
JIMENG_BASE_URL: null,
|
||||||
|
JIMENG_REGION: 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return Response.json({
|
||||||
|
code: 10000,
|
||||||
|
data: {
|
||||||
|
task_id: "task-123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
code: 10000,
|
||||||
|
data: {
|
||||||
|
status: "done",
|
||||||
|
binary_data_base64: [Buffer.from("jimeng-image").toString("base64")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const image = await generateImage(
|
||||||
|
"A quiet bamboo forest",
|
||||||
|
"jimeng_t2i_v40",
|
||||||
|
makeArgs({ quality: "normal" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(image).toString("utf8"), "jimeng-image");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.input,
|
||||||
|
"https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||||
|
assert.equal(submitBody.req_key, "jimeng_t2i_v40");
|
||||||
|
assert.equal(submitBody.prompt, "A quiet bamboo forest");
|
||||||
|
assert.ok(!("prompt_text" in submitBody));
|
||||||
|
assert.equal(submitBody.width, 1024);
|
||||||
|
assert.equal(submitBody.height, 1024);
|
||||||
|
});
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import type { CliArgs } from "../types";
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
|
type JimengSizePreset = "normal" | "2k" | "4k";
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccessKey(): string | null {
|
||||||
|
return process.env.JIMENG_ACCESS_KEY_ID || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecretKey(): string | null {
|
||||||
|
return process.env.JIMENG_SECRET_ACCESS_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegion(): string {
|
||||||
|
return process.env.JIMENG_REGION || "cn-north-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndpoint(query: Record<string, string>): {
|
||||||
|
url: string;
|
||||||
|
host: string;
|
||||||
|
canonicalUri: string;
|
||||||
|
} {
|
||||||
|
let baseUrl: URL;
|
||||||
|
try {
|
||||||
|
baseUrl = new URL(getBaseUrl());
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid JIMENG_BASE_URL: ${getBaseUrl()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl.search = "";
|
||||||
|
for (const [key, value] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
baseUrl.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: baseUrl.toString(),
|
||||||
|
host: baseUrl.host,
|
||||||
|
canonicalUri: baseUrl.pathname || "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volcengine HMAC-SHA256 signature generation
|
||||||
|
* Following the official documentation at:
|
||||||
|
* https://www.volcengine.com/docs/85621/1817045
|
||||||
|
*/
|
||||||
|
function generateSignature(
|
||||||
|
method: string,
|
||||||
|
query: Record<string, string>,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: string,
|
||||||
|
accessKey: string,
|
||||||
|
secretKey: string,
|
||||||
|
region: string,
|
||||||
|
service: string,
|
||||||
|
canonicalUri: string
|
||||||
|
): string {
|
||||||
|
// 1. Create canonical request
|
||||||
|
// Sort query parameters alphabetically
|
||||||
|
const sortedQuery = Object.entries(query)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
// Sort headers alphabetically and create canonical headers
|
||||||
|
const sortedHeaders = Object.entries(headers)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}\n`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const signedHeaders = Object.keys(headers)
|
||||||
|
.sort()
|
||||||
|
.map(k => k.toLowerCase())
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
const hashedPayload = crypto.createHash("sha256").update(body, "utf8").digest("hex");
|
||||||
|
|
||||||
|
const canonicalRequest = [
|
||||||
|
method,
|
||||||
|
canonicalUri,
|
||||||
|
sortedQuery,
|
||||||
|
sortedHeaders,
|
||||||
|
signedHeaders,
|
||||||
|
hashedPayload,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const hashedCanonicalRequest = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(canonicalRequest, "utf8")
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// 2. Create string to sign
|
||||||
|
const algorithm = "HMAC-SHA256";
|
||||||
|
const timestamp = headers["X-Date"] || headers["x-date"];
|
||||||
|
if (!timestamp) {
|
||||||
|
throw new Error("Jimeng signature generation requires an X-Date header.");
|
||||||
|
}
|
||||||
|
const dateStamp = timestamp.slice(0, 8);
|
||||||
|
|
||||||
|
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||||
|
|
||||||
|
const stringToSign = [
|
||||||
|
algorithm,
|
||||||
|
timestamp,
|
||||||
|
credentialScope,
|
||||||
|
hashedCanonicalRequest,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// 3. Calculate signature
|
||||||
|
const kDate = crypto
|
||||||
|
.createHmac("sha256", secretKey)
|
||||||
|
.update(dateStamp)
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
const kRegion = crypto.createHmac("sha256", kDate).update(region).digest();
|
||||||
|
const kService = crypto.createHmac("sha256", kRegion).update(service).digest();
|
||||||
|
const kSigning = crypto.createHmac("sha256", kService).update("request").digest();
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", kSigning)
|
||||||
|
.update(stringToSign)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// 4. Create authorization header
|
||||||
|
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse aspect ratio string like "16:9", "1:1", "4:3" into width and height
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported size presets for different quality levels
|
||||||
|
* Based on Volcengine Jimeng documentation
|
||||||
|
*/
|
||||||
|
const SIZE_PRESETS: Record<string, Record<string, string>> = {
|
||||||
|
normal: {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"4:3": "1360x1020",
|
||||||
|
"16:9": "1536x864",
|
||||||
|
"3:2": "1440x960",
|
||||||
|
"21:9": "1920x824",
|
||||||
|
},
|
||||||
|
"2k": {
|
||||||
|
"1:1": "2048x2048",
|
||||||
|
"4:3": "2304x1728",
|
||||||
|
"16:9": "2560x1440",
|
||||||
|
"3:2": "2496x1664",
|
||||||
|
"21:9": "3024x1296",
|
||||||
|
},
|
||||||
|
"4k": {
|
||||||
|
"1:1": "4096x4096",
|
||||||
|
"4:3": "4694x3520",
|
||||||
|
"16:9": "5404x3040",
|
||||||
|
"3:2": "4992x3328",
|
||||||
|
"21:9": "6198x2656",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDimensions(value: string): string | null {
|
||||||
|
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return `${match[1]}x${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
|
||||||
|
const presets = SIZE_PRESETS[qualityLevel];
|
||||||
|
const defaultSize = presets["1:1"]!;
|
||||||
|
|
||||||
|
if (!ar) return defaultSize;
|
||||||
|
|
||||||
|
const parsed = parseAspectRatio(ar);
|
||||||
|
if (!parsed) return defaultSize;
|
||||||
|
|
||||||
|
const targetRatio = parsed.width / parsed.height;
|
||||||
|
let bestMatch = defaultSize;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const [ratio, size] of Object.entries(presets)) {
|
||||||
|
const [w, h] = ratio.split(":").map(Number);
|
||||||
|
const presetRatio = w / h;
|
||||||
|
const diff = Math.abs(presetRatio - targetRatio);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
bestMatch = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageSizePreset(imageSize: string, ar: string | null): string | null {
|
||||||
|
const preset = imageSize.trim().toUpperCase();
|
||||||
|
if (preset === "1K") return getClosestPresetSize(ar, "normal");
|
||||||
|
if (preset === "2K") return getClosestPresetSize(ar, "2k");
|
||||||
|
if (preset === "4K") return getClosestPresetSize(ar, "4k");
|
||||||
|
return normalizeDimensions(imageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||||
|
if (imageSize) {
|
||||||
|
const normalizedSize = normalizeImageSizePreset(imageSize, ar);
|
||||||
|
if (normalizedSize) return normalizedSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 2K quality if not specified
|
||||||
|
const qualityLevel: JimengSizePreset = quality === "normal" ? "normal" : "2k";
|
||||||
|
return getClosestPresetSize(ar, qualityLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: Submit async task to Volcengine Jimeng API
|
||||||
|
*/
|
||||||
|
async function submitTask(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
size: string,
|
||||||
|
accessKey: string,
|
||||||
|
secretKey: string,
|
||||||
|
region: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Query parameters for submit endpoint
|
||||||
|
const query = {
|
||||||
|
Action: "CVSync2AsyncSubmitTask",
|
||||||
|
Version: "2022-08-31",
|
||||||
|
};
|
||||||
|
const endpoint = resolveEndpoint(query);
|
||||||
|
|
||||||
|
// Request body - Jimeng API expects width/height as separate integers
|
||||||
|
const [width, height] = size.split("x").map(Number);
|
||||||
|
const bodyObj = {
|
||||||
|
req_key: model,
|
||||||
|
prompt,
|
||||||
|
// Use separate width and height parameters instead of size string
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
// Optional: seed for reproducibility
|
||||||
|
// seed: Math.floor(Math.random() * 999999),
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = JSON.stringify(bodyObj);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Date": timestampHeader,
|
||||||
|
"Host": endpoint.host,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
const authorization = generateSignature(
|
||||||
|
"POST",
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
region,
|
||||||
|
"cv",
|
||||||
|
endpoint.canonicalUri
|
||||||
|
);
|
||||||
|
|
||||||
|
console.error(`Submitting task to Jimeng (${model})...`, { width, height });
|
||||||
|
|
||||||
|
const res = await fetch(endpoint.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Authorization": authorization,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Jimeng API submit error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
task_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Volcengine API returns code 10000 for success
|
||||||
|
if (result.code !== 10000 || !result.data?.task_id) {
|
||||||
|
console.error("Submit response:", JSON.stringify(result, null, 2));
|
||||||
|
throw new Error(`Failed to submit task: ${result.message || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Poll for task result
|
||||||
|
* Returns image data directly as Uint8Array
|
||||||
|
*/
|
||||||
|
async function pollForResult(
|
||||||
|
taskId: string,
|
||||||
|
model: string,
|
||||||
|
accessKey: string,
|
||||||
|
secretKey: string,
|
||||||
|
region: string
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const maxAttempts = 60;
|
||||||
|
const pollIntervalMs = 2000;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
// Query parameters for result endpoint
|
||||||
|
const query = {
|
||||||
|
Action: "CVSync2AsyncGetResult",
|
||||||
|
Version: "2022-08-31",
|
||||||
|
};
|
||||||
|
const endpoint = resolveEndpoint(query);
|
||||||
|
|
||||||
|
// Request body - include req_key and task_id
|
||||||
|
const bodyObj = {
|
||||||
|
req_key: model,
|
||||||
|
task_id: taskId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = JSON.stringify(bodyObj);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Date": timestampHeader,
|
||||||
|
"Host": endpoint.host,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
const authorization = generateSignature(
|
||||||
|
"POST",
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
region,
|
||||||
|
"cv",
|
||||||
|
endpoint.canonicalUri
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(endpoint.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Authorization": authorization,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Jimeng API poll error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
status?: string;
|
||||||
|
image_urls?: string[];
|
||||||
|
binary_data_base64?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Volcengine API returns code 10000 for success
|
||||||
|
if (result.code === 10000 && result.data) {
|
||||||
|
const { status, image_urls, binary_data_base64 } = result.data;
|
||||||
|
|
||||||
|
// Check for base64 image data (preferred by Jimeng)
|
||||||
|
if (binary_data_base64 && binary_data_base64.length > 0) {
|
||||||
|
console.error("Image received as base64 data");
|
||||||
|
const base64Data = binary_data_base64[0]!;
|
||||||
|
// Convert base64 to Uint8Array
|
||||||
|
const binaryString = Buffer.from(base64Data, "base64").toString("binary");
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to URL format
|
||||||
|
if (status === "done" && image_urls && image_urls.length > 0) {
|
||||||
|
// Download from URL
|
||||||
|
console.error(`Downloading image from ${image_urls[0]}...`);
|
||||||
|
const imgRes = await fetch(image_urls[0]!);
|
||||||
|
if (!imgRes.ok) {
|
||||||
|
throw new Error(`Failed to download image from ${image_urls[0]}`);
|
||||||
|
}
|
||||||
|
const buffer = await imgRes.arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "in_queue" || status === "generating") {
|
||||||
|
console.error(`Task status: ${status} (${attempt + 1}/${maxAttempts})`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "fail") {
|
||||||
|
throw new Error(`Jimeng task failed: ${result.message || "Generation failed"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Poll response:", JSON.stringify(result, null, 2));
|
||||||
|
throw new Error(`Unexpected response during polling: ${result.message || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Task timeout: image generation took too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Jimeng does not support reference images. Use --provider google, openai, openrouter, or replicate."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessKey = getAccessKey();
|
||||||
|
const secretKey = getSecretKey();
|
||||||
|
const region = getRegion();
|
||||||
|
|
||||||
|
if (!accessKey || !secretKey) {
|
||||||
|
throw new Error(
|
||||||
|
"JIMENG_ACCESS_KEY_ID and JIMENG_SECRET_ACCESS_KEY are required. " +
|
||||||
|
"Get your credentials from https://console.volcengine.com/iam/keymanage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = getImageSize(args.aspectRatio, args.quality, args.imageSize);
|
||||||
|
|
||||||
|
// Step 1: Submit task
|
||||||
|
const taskId = await submitTask(prompt, model, size, accessKey, secretKey, region);
|
||||||
|
|
||||||
|
// Step 2: Poll for result (returns image data directly)
|
||||||
|
const imageData = await pollForResult(taskId, model, accessKey, secretKey, region);
|
||||||
|
|
||||||
|
console.error("Image generation complete!");
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildMinimaxUrl,
|
||||||
|
buildRequestBody,
|
||||||
|
buildSubjectReference,
|
||||||
|
extractImageFromResponse,
|
||||||
|
parsePixelSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./minimax.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("MiniMax URL builder normalizes /v1 suffixes", (t) => {
|
||||||
|
useEnv(t, { MINIMAX_BASE_URL: "https://api.minimax.io" });
|
||||||
|
assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation");
|
||||||
|
|
||||||
|
process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/";
|
||||||
|
assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax size parsing and validation follow documented constraints", () => {
|
||||||
|
assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 });
|
||||||
|
assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 });
|
||||||
|
assert.equal(parsePixelSize("wide"), null);
|
||||||
|
|
||||||
|
validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 }));
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })),
|
||||||
|
/only supported with model image-01/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ size: "1537x1024" })),
|
||||||
|
/divisible by 8/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })),
|
||||||
|
/aspect_ratio must be one of/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ n: 10 })),
|
||||||
|
/at most 9 images/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const refPath = path.join(dir, "portrait.png");
|
||||||
|
await fs.writeFile(refPath, Buffer.from("portrait"));
|
||||||
|
|
||||||
|
const ratioBody = await buildRequestBody(
|
||||||
|
"A portrait by the window",
|
||||||
|
"image-01",
|
||||||
|
makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }),
|
||||||
|
);
|
||||||
|
assert.equal(ratioBody.aspect_ratio, "16:9");
|
||||||
|
assert.equal(ratioBody.n, 2);
|
||||||
|
assert.equal(ratioBody.response_format, "base64");
|
||||||
|
assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/);
|
||||||
|
|
||||||
|
const sizeBody = await buildRequestBody(
|
||||||
|
"A portrait by the window",
|
||||||
|
"image-01",
|
||||||
|
makeArgs({ size: "1536x1024" }),
|
||||||
|
);
|
||||||
|
assert.equal(sizeBody.width, 1536);
|
||||||
|
assert.equal(sizeBody.height, 1024);
|
||||||
|
assert.equal(sizeBody.aspect_ratio, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax subject references require supported file types", async (t) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const good = path.join(dir, "portrait.jpg");
|
||||||
|
const bad = path.join(dir, "portrait.webp");
|
||||||
|
await fs.writeFile(good, Buffer.from("portrait"));
|
||||||
|
await fs.writeFile(bad, Buffer.from("portrait"));
|
||||||
|
|
||||||
|
const subjectReference = await buildSubjectReference([good]);
|
||||||
|
assert.equal(subjectReference?.[0]?.type, "character");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => buildSubjectReference([bad]),
|
||||||
|
/only supports JPG, JPEG, or PNG/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax response extraction supports base64 and URL payloads", async (t) => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromBase64 = await extractImageFromResponse({
|
||||||
|
data: {
|
||||||
|
image_base64: [Buffer.from("hello").toString("base64")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
new Response(Uint8Array.from([1, 2, 3]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromUrl = await extractImageFromResponse({
|
||||||
|
data: {
|
||||||
|
image_urls: ["https://example.com/output.jpg"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }),
|
||||||
|
/blocked/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "image-01";
|
||||||
|
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
|
||||||
|
|
||||||
|
type MinimaxSubjectReference = {
|
||||||
|
type: "character";
|
||||||
|
image_file: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MinimaxRequestBody = {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
response_format: "base64";
|
||||||
|
aspect_ratio?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
n?: number;
|
||||||
|
subject_reference?: MinimaxSubjectReference[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MinimaxResponse = {
|
||||||
|
id?: string;
|
||||||
|
data?: {
|
||||||
|
image_urls?: string[];
|
||||||
|
image_base64?: string[];
|
||||||
|
};
|
||||||
|
base_resp?: {
|
||||||
|
status_code?: number;
|
||||||
|
status_msg?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.MINIMAX_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMinimaxUrl(): string {
|
||||||
|
const base = (process.env.MINIMAX_BASE_URL || "https://api.minimax.io").replace(/\/+$/g, "");
|
||||||
|
return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(filename: string): "image/jpeg" | "image/png" {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||||
|
if (ext === ".png") return "image/png";
|
||||||
|
throw new Error(
|
||||||
|
`MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePixelSize(size: string): { width: number; height: number } | null {
|
||||||
|
const match = size.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 validatePixelSize(width: number, height: number): void {
|
||||||
|
if (width < 512 || width > 2048 || height < 512 || height > 2048) {
|
||||||
|
throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
|
||||||
|
}
|
||||||
|
if (width % 8 !== 0 || height % 8 !== 0) {
|
||||||
|
throw new Error("MiniMax custom size requires width and height divisible by 8.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
if (args.n > 9) {
|
||||||
|
throw new Error("MiniMax supports at most 9 images per request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) {
|
||||||
|
throw new Error(
|
||||||
|
`MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.size && !args.aspectRatio) {
|
||||||
|
if (model !== "image-01") {
|
||||||
|
throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead.");
|
||||||
|
}
|
||||||
|
const parsed = parsePixelSize(args.size);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||||
|
}
|
||||||
|
validatePixelSize(parsed.width, parsed.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSubjectReference(
|
||||||
|
referenceImages: string[],
|
||||||
|
): Promise<MinimaxSubjectReference[] | undefined> {
|
||||||
|
if (referenceImages.length === 0) return undefined;
|
||||||
|
|
||||||
|
const subjectReference: MinimaxSubjectReference[] = [];
|
||||||
|
for (const refPath of referenceImages) {
|
||||||
|
const bytes = await readFile(refPath);
|
||||||
|
if (bytes.length > MAX_REFERENCE_IMAGE_BYTES) {
|
||||||
|
throw new Error(`MiniMax subject_reference images must be smaller than 10MB: ${refPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectReference.push({
|
||||||
|
type: "character",
|
||||||
|
image_file: `data:${getMimeType(refPath)};base64,${bytes.toString("base64")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjectReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<MinimaxRequestBody> {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const body: MinimaxRequestBody = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
response_format: "base64",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.aspectRatio) {
|
||||||
|
body.aspect_ratio = args.aspectRatio;
|
||||||
|
} else if (args.size) {
|
||||||
|
const parsed = parsePixelSize(args.size);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||||
|
}
|
||||||
|
body.width = parsed.width;
|
||||||
|
body.height = parsed.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.n > 1) {
|
||||||
|
body.n = args.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectReference = await buildSubjectReference(args.referenceImages);
|
||||||
|
if (subjectReference) {
|
||||||
|
body.subject_reference = subjectReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image from MiniMax: ${response.status}`);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractImageFromResponse(result: MinimaxResponse): Promise<Uint8Array> {
|
||||||
|
const baseResp = result.base_resp;
|
||||||
|
if (baseResp && baseResp.status_code !== undefined && baseResp.status_code !== 0) {
|
||||||
|
throw new Error(baseResp.status_msg || `MiniMax API returned status_code=${baseResp.status_code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Image = result.data?.image_base64?.[0];
|
||||||
|
if (base64Image) {
|
||||||
|
return Uint8Array.from(Buffer.from(base64Image, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = result.data?.image_urls?.[0];
|
||||||
|
if (url) {
|
||||||
|
return downloadImage(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No image data in MiniMax response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultOutputExtension(): ".jpg" {
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimax.io/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await buildRequestBody(prompt, model, args);
|
||||||
|
const response = await fetch(buildMinimaxUrl(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`MiniMax API error (${response.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as MinimaxResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractImageFromResponse,
|
||||||
|
getMimeType,
|
||||||
|
getOpenAISize,
|
||||||
|
parseAspectRatio,
|
||||||
|
} from "./openai.ts";
|
||||||
|
|
||||||
|
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||||
|
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||||
|
assert.equal(parseAspectRatio("wide"), null);
|
||||||
|
assert.equal(parseAspectRatio("0:1"), null);
|
||||||
|
|
||||||
|
assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024");
|
||||||
|
assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792");
|
||||||
|
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||||
|
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||||
|
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||||
|
assert.equal(getMimeType("frame.png"), "image/png");
|
||||||
|
assert.equal(getMimeType("frame.jpg"), "image/jpeg");
|
||||||
|
assert.equal(getMimeType("frame.webp"), "image/webp");
|
||||||
|
assert.equal(getMimeType("frame.gif"), "image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenAI response extraction supports base64 and URL download flows", async (t) => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromBase64 = await extractImageFromResponse({
|
||||||
|
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
new Response(Uint8Array.from([1, 2, 3]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromUrl = await extractImageFromResponse({
|
||||||
|
data: [{ url: "https://example.com/image.png" }],
|
||||||
|
});
|
||||||
|
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => extractImageFromResponse({ data: [{}] }),
|
||||||
|
/No image in response/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||||
|
|
||||||
|
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||||
|
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const w = parseFloat(match[1]!);
|
||||||
|
const h = parseFloat(match[2]!);
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
type SizeMapping = {
|
||||||
|
square: string;
|
||||||
|
landscape: string;
|
||||||
|
portrait: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
||||||
|
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 error: ${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("No image found in chat completions response");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithOpenAIGenerations(
|
||||||
|
baseURL: string,
|
||||||
|
apiKey: string,
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
size: string,
|
||||||
|
quality: CliArgs["quality"]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const body: Record<string, any> = { model, prompt, size };
|
||||||
|
|
||||||
|
if (model.includes("dall-e-3")) {
|
||||||
|
body.quality = quality === "2k" ? "hd" : "standard";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 error: ${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);
|
||||||
|
|
||||||
|
if (model.includes("gpt-image")) {
|
||||||
|
form.append("quality", quality === "2k" ? "high" : "medium");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 edits API error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as OpenAIImageResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMimeType(filename: string): string {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||||
|
if (ext === ".webp") return "image/webp";
|
||||||
|
if (ext === ".gif") return "image/gif";
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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("Failed to download image");
|
||||||
|
const buf = await imgRes.arrayBuffer();
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No image in response");
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildContent,
|
||||||
|
buildRequestBody,
|
||||||
|
extractImageFromResponse,
|
||||||
|
getAspectRatio,
|
||||||
|
getImageSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./openrouter.ts";
|
||||||
|
|
||||||
|
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const GEMINI_25_MODEL = "google/gemini-2.5-flash-image";
|
||||||
|
const GPT_5_IMAGE_MODEL = "openai/gpt-5-image";
|
||||||
|
const OPENROUTER_AUTO_MODEL = "openrouter/auto";
|
||||||
|
const FLUX_MODEL = "black-forest-labs/flux.2-pro";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
||||||
|
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
|
||||||
|
const body = buildRequestBody("hello", GEMINI_MODEL, args, []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
aspect_ratio: "16:9",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps text+image modalities for current text+image models", () => {
|
||||||
|
for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) {
|
||||||
|
const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter helper omits image_config when no size or quality is passed", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []);
|
||||||
|
|
||||||
|
assert.equal(body.image_config, undefined);
|
||||||
|
assert.equal(body.provider, undefined);
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps multimodal array content when references are provided", () => {
|
||||||
|
const content = buildContent("hello", ["data:image/png;base64,abc"]);
|
||||||
|
assert.ok(Array.isArray(content));
|
||||||
|
assert.deepEqual(content[0], { type: "text", text: "hello" });
|
||||||
|
assert.deepEqual(content[1], {
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: "data:image/png;base64,abc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter size and aspect helpers infer supported values", () => {
|
||||||
|
assert.equal(getImageSize(makeArgs()), null);
|
||||||
|
assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K");
|
||||||
|
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })),
|
||||||
|
/does not support size 2048x1024 \(aspect ratio 2:1\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
|
||||||
|
const bytes = await extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: "error",
|
||||||
|
native_finish_reason: "MALFORMED_FUNCTION_CALL",
|
||||||
|
message: { content: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/finish_reason=MALFORMED_FUNCTION_CALL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const COMMON_ASPECT_RATIOS = [
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];
|
||||||
|
|
||||||
|
type OpenRouterImageEntry = {
|
||||||
|
image_url?: string | { url?: string | null } | null;
|
||||||
|
imageUrl?: string | { url?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenRouterMessagePart = {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
image_url?: string | { url?: string | null } | null;
|
||||||
|
imageUrl?: string | { url?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenRouterResponse = {
|
||||||
|
choices?: Array<{
|
||||||
|
finish_reason?: string | null;
|
||||||
|
native_finish_reason?: string | null;
|
||||||
|
message?: {
|
||||||
|
images?: OpenRouterImageEntry[];
|
||||||
|
content?: string | OpenRouterMessagePart[] | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelId(model: string): string {
|
||||||
|
return model.trim().toLowerCase().split(":")[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextAndImageModel(model: string): boolean {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized === "openrouter/auto") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedAspectRatios(model: string): Set<string> {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized !== "google/gemini-3.1-flash-image-preview") {
|
||||||
|
return new Set(COMMON_ASPECT_RATIOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.OPENROUTER_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
const base = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
||||||
|
return base.replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaders(apiKey: string): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const referer = process.env.OPENROUTER_HTTP_REFERER?.trim();
|
||||||
|
if (referer) {
|
||||||
|
headers["HTTP-Referer"] = referer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = process.env.OPENROUTER_TITLE?.trim();
|
||||||
|
if (title) {
|
||||||
|
headers["X-OpenRouter-Title"] = title;
|
||||||
|
headers["X-Title"] = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 inferAspectRatio(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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageSize(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 getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {
|
||||||
|
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||||
|
|
||||||
|
const inferredFromSize = inferImageSize(args.size);
|
||||||
|
if (inferredFromSize) return inferredFromSize;
|
||||||
|
|
||||||
|
if (args.quality === "normal") return "1K";
|
||||||
|
if (args.quality === "2k") return "2K";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAspectRatio(model: string, args: CliArgs): string | null {
|
||||||
|
if (args.aspectRatio) return args.aspectRatio;
|
||||||
|
|
||||||
|
const inferred = inferAspectRatio(args.size);
|
||||||
|
if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModalities(model: string): string[] {
|
||||||
|
return isTextAndImageModel(model) ? ["image", "text"] : ["image"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);
|
||||||
|
if (!requestedAspectRatio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = getSupportedAspectRatios(model);
|
||||||
|
if (supported.has(requestedAspectRatio)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedValue = args.aspectRatio
|
||||||
|
? `aspect ratio ${requestedAspectRatio}`
|
||||||
|
: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 readImageAsDataUrl(filePath: string): Promise<string> {
|
||||||
|
const bytes = await readFile(filePath);
|
||||||
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContent(
|
||||||
|
prompt: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): string | Array<Record<string, unknown>> {
|
||||||
|
if (referenceImages.length === 0) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
||||||
|
|
||||||
|
for (const imageUrl of referenceImages) {
|
||||||
|
content.push({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: imageUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImageUrl(entry: OpenRouterImageEntry | OpenRouterMessagePart): string | null {
|
||||||
|
const value = entry.image_url ?? entry.imageUrl;
|
||||||
|
if (!value) return null;
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return value.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDataUrl(value: string): Uint8Array | null {
|
||||||
|
const match = value.match(/^data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(value: string): Promise<Uint8Array> {
|
||||||
|
const inline = decodeDataUrl(value);
|
||||||
|
if (inline) return inline;
|
||||||
|
|
||||||
|
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||||
|
const response = await fetch(value);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download OpenRouter image: ${response.status}`);
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||||
|
const choice = result.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
|
||||||
|
for (const image of message?.images ?? []) {
|
||||||
|
const imageUrl = extractImageUrl(image);
|
||||||
|
if (imageUrl) return downloadImage(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(message?.content)) {
|
||||||
|
for (const item of message.content) {
|
||||||
|
const imageUrl = extractImageUrl(item);
|
||||||
|
if (imageUrl) return downloadImage(imageUrl);
|
||||||
|
|
||||||
|
if (item.type === "text" && item.text) {
|
||||||
|
const inline = decodeDataUrl(item.text);
|
||||||
|
if (inline) return inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof message?.content === "string") {
|
||||||
|
const inline = decodeDataUrl(message.content);
|
||||||
|
if (inline) return inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishReason =
|
||||||
|
choice?.native_finish_reason || choice?.finish_reason || "unknown";
|
||||||
|
throw new Error(
|
||||||
|
`No image in OpenRouter response (finish_reason=${finishReason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const imageConfig: Record<string, string> = {};
|
||||||
|
|
||||||
|
const imageSize = getImageSize(args);
|
||||||
|
if (imageSize) {
|
||||||
|
imageConfig.image_size = imageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio(model, args);
|
||||||
|
if (aspectRatio) {
|
||||||
|
imageConfig.aspect_ratio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: buildContent(prompt, referenceImages),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modalities: getModalities(model),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(imageConfig).length > 0) {
|
||||||
|
body.image_config = imageConfig;
|
||||||
|
body.provider = {
|
||||||
|
require_parameters: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("OPENROUTER_API_KEY is required. Get one at https://openrouter.ai/settings/keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceImages: string[] = [];
|
||||||
|
for (const refPath of args.referenceImages) {
|
||||||
|
referenceImages.push(await readImageAsDataUrl(refPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
...buildRequestBody(prompt, model, args, referenceImages),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Generating image with OpenRouter (${model})...`,
|
||||||
|
(body.image_config as Record<string, string>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getHeaders(apiKey),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenRouter API error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as OpenRouterResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildInput,
|
||||||
|
extractOutputUrl,
|
||||||
|
parseModelId,
|
||||||
|
} from "./replicate.ts";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||||
|
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||||
|
owner: "google",
|
||||||
|
name: "nano-banana-pro",
|
||||||
|
version: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||||
|
owner: "owner",
|
||||||
|
name: "model",
|
||||||
|
version: "abc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => parseModelId("just-a-model-name"),
|
||||||
|
/Invalid Replicate model format/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildInput(
|
||||||
|
"A robot painter",
|
||||||
|
makeArgs({
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
quality: "2k",
|
||||||
|
n: 3,
|
||||||
|
}),
|
||||||
|
["data:image/png;base64,AAAA"],
|
||||||
|
),
|
||||||
|
{
|
||||||
|
prompt: "A robot painter",
|
||||||
|
aspect_ratio: "16:9",
|
||||||
|
number_of_images: 3,
|
||||||
|
resolution: "2K",
|
||||||
|
output_format: "png",
|
||||||
|
image_input: ["data:image/png;base64,AAAA"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||||
|
{
|
||||||
|
prompt: "A robot painter",
|
||||||
|
aspect_ratio: "match_input_image",
|
||||||
|
resolution: "1K",
|
||||||
|
output_format: "png",
|
||||||
|
image_input: ["ref"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||||
|
assert.equal(
|
||||||
|
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||||
|
"https://example.com/a.png",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
extractOutputUrl({ output: ["https://example.com/b.png"] } as never),
|
||||||
|
"https://example.com/b.png",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never),
|
||||||
|
"https://example.com/c.png",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||||
|
/Unexpected Replicate output format/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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(
|
||||||
|
`Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { owner: parts[0], name: parts[1], version: version || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||||
|
const input: Record<string, unknown> = { prompt };
|
||||||
|
|
||||||
|
if (args.aspectRatio) {
|
||||||
|
input.aspect_ratio = args.aspectRatio;
|
||||||
|
} else if (referenceImages.length > 0) {
|
||||||
|
input.aspect_ratio = "match_input_image";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.n > 1) {
|
||||||
|
input.number_of_images = args.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.quality === "normal") {
|
||||||
|
input.resolution = "1K";
|
||||||
|
} else if (args.quality === "2k") {
|
||||||
|
input.resolution = "2K";
|
||||||
|
}
|
||||||
|
|
||||||
|
input.output_format = "png";
|
||||||
|
|
||||||
|
if (referenceImages.length > 0) {
|
||||||
|
input.image_input = referenceImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 error (${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 poll error (${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 ${prediction.status}: ${prediction.error || "unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||||
|
const output = prediction.output;
|
||||||
|
|
||||||
|
if (typeof output === "string") return output;
|
||||||
|
|
||||||
|
if (Array.isArray(output)) {
|
||||||
|
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(`Unexpected Replicate output format: ${JSON.stringify(output)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to download image from 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 is required. Get one at https://replicate.com/account/api-tokens");
|
||||||
|
|
||||||
|
const parsedModel = parseModelId(model);
|
||||||
|
|
||||||
|
const refDataUrls: string[] = [];
|
||||||
|
for (const refPath of args.referenceImages) {
|
||||||
|
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = buildInput(prompt, args, refDataUrls);
|
||||||
|
|
||||||
|
console.log(`Generating image with Replicate (${model})...`);
|
||||||
|
|
||||||
|
let prediction = await createPrediction(apiToken, parsedModel, input, true);
|
||||||
|
|
||||||
|
if (prediction.status !== "succeeded") {
|
||||||
|
if (!prediction.urls?.get) {
|
||||||
|
throw new Error("Replicate prediction did not return a poll URL");
|
||||||
|
}
|
||||||
|
console.log("Waiting for prediction to complete...");
|
||||||
|
prediction = await pollPrediction(apiToken, prediction.urls.get);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Generation completed.");
|
||||||
|
|
||||||
|
const outputUrl = extractOutputUrl(prediction);
|
||||||
|
return downloadImage(outputUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildImageInput,
|
||||||
|
buildRequestBody,
|
||||||
|
generateImage,
|
||||||
|
getDefaultOutputExtension,
|
||||||
|
resolveSeedreamSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./seedream.ts";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
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 makeTempPng(t: TestContext, name: string): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "seedream-test-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
const png1x1 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7m0AAAAASUVORK5CYII=";
|
||||||
|
await fs.writeFile(filePath, Buffer.from(png1x1, "base64"));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Seedream request body and default extensions follow official model capabilities", () => {
|
||||||
|
const five = buildRequestBody(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-5-0-260128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
assert.equal(five.size, "2K");
|
||||||
|
assert.equal(five.response_format, "url");
|
||||||
|
assert.equal(five.output_format, "png");
|
||||||
|
assert.equal(getDefaultOutputExtension("doubao-seedream-5-0-260128"), ".png");
|
||||||
|
|
||||||
|
const fourFive = buildRequestBody(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-4-5-251128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
assert.equal(fourFive.size, "2K");
|
||||||
|
assert.equal(fourFive.response_format, "url");
|
||||||
|
assert.ok(!("output_format" in fourFive));
|
||||||
|
assert.equal(getDefaultOutputExtension("doubao-seedream-4-5-251128"), ".jpg");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildRequestBody(
|
||||||
|
"Change the bubbles into hearts",
|
||||||
|
"doubao-seededit-3-0-i2i-250628",
|
||||||
|
makeArgs({ referenceImages: ["ref.png"] }),
|
||||||
|
"data:image/png;base64,AAAA",
|
||||||
|
),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream size selection validates model-specific presets", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })),
|
||||||
|
"1K",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ quality: "2k" })),
|
||||||
|
"2048x2048",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ size: "4K" })),
|
||||||
|
/only supports 2K, 3K/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })),
|
||||||
|
/only supports explicit WxH sizes/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seededit-3-0-i2i-250628", makeArgs({ size: "1024x1024" })),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream reference-image support is model-specific", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seedream-5-0-260128",
|
||||||
|
makeArgs({ referenceImages: ["a.png", "b.png"] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seedream-3-0-t2i-250415",
|
||||||
|
makeArgs({ referenceImages: ["a.png"] }),
|
||||||
|
),
|
||||||
|
/does not support reference images/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seededit-3-0-i2i-250628",
|
||||||
|
makeArgs(),
|
||||||
|
),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"ep-20260315171508-t8br2",
|
||||||
|
makeArgs({ referenceImages: ["a.png"] }),
|
||||||
|
),
|
||||||
|
/require a known model ID/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream image input encodes local references as data URLs", async (t) => {
|
||||||
|
const refOne = await makeTempPng(t, "one.png");
|
||||||
|
const refTwo = await makeTempPng(t, "two.png");
|
||||||
|
|
||||||
|
const single = await buildImageInput("doubao-seedream-4-5-251128", [refOne]);
|
||||||
|
assert.match(String(single), /^data:image\/png;base64,/);
|
||||||
|
|
||||||
|
const multiple = await buildImageInput("doubao-seedream-5-0-260128", [refOne, refTwo]);
|
||||||
|
assert.ok(Array.isArray(multiple));
|
||||||
|
assert.equal(multiple.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream generateImage posts the documented response_format and downloads the returned URL", async (t) => {
|
||||||
|
useEnv(t, { ARK_API_KEY: "test-key", SEEDREAM_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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return Response.json({
|
||||||
|
model: "doubao-seedream-4-5-251128",
|
||||||
|
created: 1740000000,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/generated-image",
|
||||||
|
size: "2048x2048",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
generated_images: 1,
|
||||||
|
output_tokens: 1,
|
||||||
|
total_tokens: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(Uint8Array.from([7, 8, 9]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const image = await generateImage(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-4-5-251128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual([...image], [7, 8, 9]);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.input,
|
||||||
|
"https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||||
|
assert.equal(requestBody.model, "doubao-seedream-4-5-251128");
|
||||||
|
assert.equal(requestBody.size, "2K");
|
||||||
|
assert.equal(requestBody.response_format, "url");
|
||||||
|
assert.ok(!("output_format" in requestBody));
|
||||||
|
assert.equal(calls[1]?.input, "https://example.com/generated-image");
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
export type SeedreamModelFamily =
|
||||||
|
| "seedream5"
|
||||||
|
| "seedream45"
|
||||||
|
| "seedream40"
|
||||||
|
| "seedream30"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
type SeedreamRequestImage = string | string[];
|
||||||
|
|
||||||
|
type SeedreamRequestBody = {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
size: string;
|
||||||
|
response_format: "url";
|
||||||
|
watermark: boolean;
|
||||||
|
image?: SeedreamRequestImage;
|
||||||
|
output_format?: "png";
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedreamImageResponse = {
|
||||||
|
model?: string;
|
||||||
|
created?: number;
|
||||||
|
data?: Array<{
|
||||||
|
url?: string;
|
||||||
|
b64_json?: string;
|
||||||
|
size?: string;
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
usage?: {
|
||||||
|
generated_images: number;
|
||||||
|
output_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.ARK_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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";
|
||||||
|
if (ext === ".bmp") return "image/bmp";
|
||||||
|
if (ext === ".tiff" || ext === ".tif") return "image/tiff";
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||||
|
const bytes = await readFile(filePath);
|
||||||
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelFamily(model: string): SeedreamModelFamily {
|
||||||
|
const normalized = model.trim();
|
||||||
|
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";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemovedSeededitModel(model: string): boolean {
|
||||||
|
return /^doubao-seededit-3-0-i2i-\d+$/.test(model.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedModel(model: string): void {
|
||||||
|
if (isRemovedSeededitModel(model)) {
|
||||||
|
throw new Error(
|
||||||
|
`${model} is no longer supported. SeedEdit 3.0 support has been removed from this tool; use Seedream 5.0/4.5/4.0/3.0 instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsReferenceImages(model: string): boolean {
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
return family === "seedream5" || family === "seedream45" || family === "seedream40";
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsOutputFormat(model: string): boolean {
|
||||||
|
return getModelFamily(model) === "seedream5";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultOutputExtension(model: string): ".png" | ".jpg" {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
return supportsOutputFormat(model) ? ".png" : ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSeedreamSize(model: string, args: CliArgs): string {
|
||||||
|
assertSupportedModel(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 {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
const requested = args.size || args.imageSize || null;
|
||||||
|
const normalized = requested ? normalizeSizeValue(requested) : null;
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return getDefaultSeedreamSize(model, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream30") {
|
||||||
|
const pixelSize = normalizePixelSize(normalized);
|
||||||
|
if (!pixelSize) {
|
||||||
|
throw new Error("Seedream 3.0 only supports explicit WxH sizes such as 1024x1024.");
|
||||||
|
}
|
||||||
|
return pixelSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream5") {
|
||||||
|
if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 5.0 only supports 2K, 3K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream45") {
|
||||||
|
if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 4.5 only supports 2K, 4K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream40") {
|
||||||
|
if (normalized === "3K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 4.0 only supports 1K, 2K, 4K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "adaptive") {
|
||||||
|
throw new Error("Adaptive size is not supported by Seedream image generation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "1K" || normalized === "3K" || normalized === "4K") {
|
||||||
|
throw new Error(
|
||||||
|
"Unknown Seedream model ID. Use a documented model ID or pass an explicit WxH size instead of preset imageSize."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
const refCount = args.referenceImages.length;
|
||||||
|
|
||||||
|
if (refCount === 0) {
|
||||||
|
resolveSeedreamSize(model, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "unknown") {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images with Seedream require a known model ID. Use Seedream 5.0/4.5/4.0 model IDs instead of an endpoint ID."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsReferenceImages(model)) {
|
||||||
|
throw new Error(`${model} does not support reference images.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((family === "seedream5" || family === "seedream45" || family === "seedream40") && refCount > 14) {
|
||||||
|
throw new Error(`${model} supports at most 14 reference images.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveSeedreamSize(model, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildImageInput(
|
||||||
|
model: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Promise<SeedreamRequestImage | undefined> {
|
||||||
|
if (referenceImages.length === 0) return undefined;
|
||||||
|
assertSupportedModel(model);
|
||||||
|
|
||||||
|
const encoded = await Promise.all(referenceImages.map((refPath) => readImageAsDataUrl(refPath)));
|
||||||
|
|
||||||
|
return encoded.length === 1 ? encoded[0]! : encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
imageInput?: SeedreamRequestImage,
|
||||||
|
): SeedreamRequestBody {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const requestBody: SeedreamRequestBody = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
size: resolveSeedreamSize(model, args),
|
||||||
|
response_format: "url",
|
||||||
|
watermark: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageInput) {
|
||||||
|
requestBody.image = imageInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsOutputFormat(model)) {
|
||||||
|
requestBody.output_format = "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||||
|
const imgResponse = await fetch(url);
|
||||||
|
if (!imgResponse.ok) {
|
||||||
|
throw new Error(`Failed to download image from ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await imgResponse.arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractImageFromResponse(result: SeedreamImageResponse): Promise<Uint8Array> {
|
||||||
|
const first = result.data?.find((item) => item.url || item.b64_json || item.error);
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
throw new Error("No image data in Seedream response");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.error) {
|
||||||
|
throw new Error(first.error.message || "Seedream returned an image generation error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.b64_json) {
|
||||||
|
return Uint8Array.from(Buffer.from(first.b64_json, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.url) {
|
||||||
|
console.error(`Downloading image from ${first.url}...`);
|
||||||
|
return downloadImage(first.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No image URL or base64 data in Seedream response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"ARK_API_KEY is required. " +
|
||||||
|
"Get your API key from https://console.volcengine.com/ark"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateArgs(model, args);
|
||||||
|
const imageInput = await buildImageInput(model, args.referenceImages);
|
||||||
|
const requestBody = buildRequestBody(prompt, model, args, imageInput);
|
||||||
|
|
||||||
|
console.error(`Calling Seedream API (${model}) with size: ${requestBody.size}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${getBaseUrl()}/images/generations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as SeedreamImageResponse;
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message || "Seedream API returned an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type Provider =
|
||||||
|
| "google"
|
||||||
|
| "openai"
|
||||||
|
| "openrouter"
|
||||||
|
| "dashscope"
|
||||||
|
| "minimax"
|
||||||
|
| "replicate"
|
||||||
|
| "jimeng"
|
||||||
|
| "seedream"
|
||||||
|
| "azure";
|
||||||
|
export type Quality = "normal" | "2k";
|
||||||
|
|
||||||
|
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;
|
||||||
|
referenceImages: string[];
|
||||||
|
n: number;
|
||||||
|
batchFile: string | null;
|
||||||
|
jobs: number | null;
|
||||||
|
json: boolean;
|
||||||
|
help: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchTaskInput = {
|
||||||
|
id?: string;
|
||||||
|
prompt?: string | null;
|
||||||
|
promptFiles?: string[];
|
||||||
|
image?: string;
|
||||||
|
provider?: Provider | null;
|
||||||
|
model?: string | null;
|
||||||
|
ar?: string | null;
|
||||||
|
size?: string | null;
|
||||||
|
quality?: Quality | null;
|
||||||
|
imageSize?: "1K" | "2K" | "4K" | null;
|
||||||
|
ref?: string[];
|
||||||
|
n?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchFile =
|
||||||
|
| BatchTaskInput[]
|
||||||
|
| {
|
||||||
|
tasks: BatchTaskInput[];
|
||||||
|
jobs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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_model: {
|
||||||
|
google: string | null;
|
||||||
|
openai: string | null;
|
||||||
|
openrouter: string | null;
|
||||||
|
dashscope: string | null;
|
||||||
|
minimax: string | null;
|
||||||
|
replicate: string | null;
|
||||||
|
jimeng: string | null;
|
||||||
|
seedream: string | null;
|
||||||
|
azure: string | null;
|
||||||
|
};
|
||||||
|
batch?: {
|
||||||
|
max_workers?: number | null;
|
||||||
|
provider_limits?: Partial<
|
||||||
|
Record<
|
||||||
|
Provider,
|
||||||
|
{
|
||||||
|
concurrency?: number | null;
|
||||||
|
start_interval_ms?: number | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user