feat\!: rename baoyu-imagine→baoyu-image-gen, baoyu-image-cards→baoyu-xhs-images (v2.0.0)

BREAKING CHANGE: removed `baoyu-imagine` and `baoyu-image-cards`. All
functionality now lives under `baoyu-image-gen` and `baoyu-xhs-images`
respectively. Cross-skill `## Image Generation Tools` examples updated
across baoyu-article-illustrator, baoyu-comic, baoyu-cover-image,
baoyu-infographic, and baoyu-slide-deck.

Migration: existing `~/.baoyu-skills/baoyu-imagine/EXTEND.md` configs are
auto-renamed to `…/baoyu-image-gen/EXTEND.md` on first run via the
legacy-path resolver in `scripts/main.ts`.
This commit is contained in:
Jim Liu 宝玉
2026-05-24 18:35:05 -05:00
parent bc303345f4
commit c3cbce9ce3
116 changed files with 2117 additions and 12789 deletions
+23 -15
View File
@@ -1,7 +1,7 @@
---
name: baoyu-image-gen
description: "[Deprecated: use baoyu-imagine] AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, 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
description: AI image generation with OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, 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: 2.0.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
@@ -11,10 +11,9 @@ metadata:
- npx
---
# Image Generation (AI SDK)
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate.
Official API-based image generation. Supports OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate.
## User Input Tools
@@ -45,6 +44,8 @@ Check these paths in order; first hit wins:
- **Found** → load, parse, apply. If `default_model.[provider]` is null → ask model only.
- **Not found** → run first-time setup (`references/config/first-time-setup.md`) using AskUserQuestion to collect provider + model + quality + save location. Save EXTEND.md, then continue. Do not generate images before this completes.
Legacy compatibility: if `.baoyu-skills/baoyu-imagine/EXTEND.md` exists and the new path doesn't, the runtime renames it to `baoyu-image-gen`. If both exist, the runtime leaves them alone and uses the new path.
**EXTEND.md keys**: default provider, default quality, default aspect ratio, default image size, OpenAI image API dialect, default models, batch worker cap, provider-specific batch limits. Schema: `references/config/preferences-schema.md`.
## Usage
@@ -77,6 +78,9 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref so
# Specific provider
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider dashscope --model qwen-image-2.0-pro
# OpenAI GPT Image 2
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
# Batch mode
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
```
@@ -103,11 +107,11 @@ When the user wants a person/object preserved from reference images:
| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
| `--model <id>`, `-m` | Model ID — see provider references for defaults and allowed values |
| `--ar <ratio>` | Aspect ratio (`16:9`, `1:1`, `4:3`, …) |
| `--size <WxH>` | Explicit size (e.g., `1024x1024`) |
| `--size <WxH>` | Explicit size (e.g., `1024x1024`; for `gpt-image-2`, width/height must be multiples of 16, max edge 3840px, ratio no wider than 3:1) |
| `--quality normal\|2k` | Quality preset (default: `2k`) |
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
| `--imageApiDialect openai-native\|ratio-metadata` | OpenAI-compatible endpoint dialect — use `ratio-metadata` for gateways that expect aspect-ratio `size` plus `metadata.resolution` |
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0 |
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0, DashScope `wan2.7-image-pro`/`wan2.7-image`. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0, or any DashScope model outside the `wan2.7-image*` family |
| `--n <count>` | Number of images. Replicate requires `--n 1` (single-output save semantics) |
| `--json` | JSON output |
@@ -140,15 +144,15 @@ When the user wants a person/object preserved from reference images:
### Codex/ChatGPT OAuth is not an OpenAI API key
`--provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
`--provider openai --model gpt-image-2` uses the standard OpenAI Images API (`/v1/images/generations` or `/v1/images/edits`) and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider:
- In Codex runtime: use the native `imagegen` skill/tool.
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it (for example `baoyu-cover-image`). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added to `baoyu-image-gen`, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
## Model Resolution
@@ -159,7 +163,9 @@ Priority (highest → lowest) applies to every provider:
3. Env var `<PROVIDER>_IMAGE_MODEL`
4. Built-in default
For Azure, `--model` / `default_model.azure` is the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var; `AZURE_OPENAI_IMAGE_MODEL` is kept as a backward-compatible alias.
For OpenAI, the built-in default is `gpt-image-2`. `gpt-image-1.5`, `gpt-image-1`, and GPT Image snapshots remain selectable with `--model` or `OPENAI_IMAGE_MODEL`.
For Azure, `--model` / `default_model.azure` is the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var; `AZURE_OPENAI_IMAGE_MODEL` is kept as a backward-compatible alias. If your Azure deployment is named after the underlying model, use `gpt-image-2`; otherwise use the exact custom deployment name.
EXTEND.md overrides env vars: if EXTEND.md sets `default_model.google: "gemini-3-pro-image-preview"` and the env var sets `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview`, EXTEND.md wins.
@@ -200,17 +206,19 @@ Each provider has its own quirks (model families, size rules, ref support, limit
| 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 |
| `normal` | 1K | 1024px target | 1K | 1K | Quick previews |
| `2k` (default) | 2K | 2048px target | 2K | 2K | Covers, illustrations, infographics |
Google/OpenRouter `imageSize` can be overridden with `--imageSize 1K|2K|4K`.
For OpenAI native `gpt-image-2`, `normal` maps to `quality=medium` and a low-latency valid size near the requested aspect ratio; `2k` maps to `quality=high` and 2048px-class sizes such as `2048x2048`, `2048x1152`, or `1152x2048`. Use explicit `--size` for valid custom or 4K outputs, e.g. `3840x2160`.
## Aspect Ratios
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`.
- Google multimodal: `imageConfig.aspectRatio`
- OpenAI: closest supported size
- OpenAI: `gpt-image-2` uses the closest valid custom size for the requested ratio; older GPT Image and DALL·E models use their closest supported fixed size
- OpenRouter: `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, the ratio is inferred
- Replicate: behavior is model-specific — `google/nano-banana*` uses `aspect_ratio`, `bytedance/seedream-*` uses documented Replicate ratios, Wan 2.7 maps `--ar` to a concrete `size`
- MiniMax: official `aspect_ratio` values; if `--size <WxH>` is given without `--ar`, sends `width`/`height` for `image-01`
@@ -245,14 +253,14 @@ Rule of thumb: once prompt files are saved and the task is "generate all of thes
### Codex image2 fallback
If `--provider openai` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
If `--provider openai --model gpt-image-2` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
## References
| File | Content |
|------|---------|
| `references/usage-examples.md` | Extended CLI examples across providers and batch mode |
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through the standard OpenAI API-key provider |
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through baoyu-image-gen's standard OpenAI API-key provider |
| `references/codex-image2-fallback.md` | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
| `references/providers/dashscope.md` | DashScope families, sizes, limits |
| `references/providers/zai.md` | Z.AI GLM-image / cogview-4 |
@@ -1,11 +1,11 @@
---
name: codex-image2-fallback
description: Fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available
description: Fallback behavior when baoyu-image-gen lacks OpenAI API credentials but Codex/native image generation is available
---
# Codex Image2 Fallback
The `openai` provider can fail with:
When using `baoyu-image-gen` with `--provider openai --model gpt-image-2`, the CLI can fail with:
```text
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.
@@ -15,7 +15,7 @@ This is expected. The `openai` provider uses the public OpenAI Images API and ne
## Practical fallback pattern
1. Try this skill when provider credentials are available.
1. Try `baoyu-image-gen` when provider credentials are available.
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
3. Prefer a Codex/native raster backend in this order:
- Codex runtime native `imagegen` skill/tool, if available.
@@ -25,3 +25,11 @@ This is expected. The `openai` provider uses the public OpenAI Images API and ne
- If the fallback backend accepts references, pass the reference images.
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
5. Return the generated media path or structured backend error promptly.
## User-facing wording
Use concise wording such as:
> The OpenAI API path needs `OPENAI_API_KEY`; Codex login is a separate image2 backend. I used the available Codex/native image backend instead. Reference images were [passed directly / reconstructed from visual traits].
Avoid implying that `baoyu-image-gen --provider openai` can use Codex OAuth without a dedicated provider implementation.
@@ -1,20 +1,18 @@
# Codex OAuth vs OpenAI API key
# Codex OAuth vs OpenAI API key for baoyu-image-gen
This deprecated skill keeps cross-cutting image-generation guidance in sync with `baoyu-imagine`.
`baoyu-image-gen --provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
The `openai` provider uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make the existing `openai` provider work because the auth, route, and payload shape differ.
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make baoyu-image-gen's existing `openai` provider work because the auth, route, and payload shape differ.
## What to use instead
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it.
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. The wrapper invokes `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required.
- If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits.
- If first-class Codex OAuth support is added, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
- If the user wants `baoyu-image-gen` itself to support Codex OAuth, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
## Reference-image prompting note
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Prefer direct wording:
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Long descriptions can cause the model to synthesize a new similar-looking person/object. Prefer direct wording:
> Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
@@ -46,19 +46,19 @@ options:
- label: "Google (Recommended)"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image - consistent quality, reliable output"
description: "GPT Image 2 - latest OpenAI image model, reference-image workflows"
- 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: "Z.AI"
description: "GLM-image, strong poster and text-heavy image generation"
- label: "MiniMax"
description: "MiniMax image generation with subject-reference character workflows"
- label: "Replicate"
description: "Community models - nano-banana-pro, flexible model selection"
- label: "Z.AI"
description: "GLM-Image - text-to-image with recommended aspect sizes"
description: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
```
### Question 2: Default Google Model
@@ -101,10 +101,12 @@ Only show if user selected Azure OpenAI.
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"
- label: "gpt-image-2 (Recommended)"
description: "Use if your Azure deployment uses the GPT Image 2 model name"
- label: "gpt-image-1.5"
description: "Previous GPT Image deployment name"
- label: "gpt-image-1"
description: "Earlier GPT Image deployment name"
```
### Question 2d: Default MiniMax Model
@@ -130,11 +132,9 @@ header: "Z.AI Model"
question: "Default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Latest GLM-Image, best aspect-ratio coverage and text rendering"
description: "Best default for posters, diagrams, and text-heavy images"
- label: "cogview-4-250304"
description: "Legacy CogView-4 model with 16-pixel size stepping"
- label: "cogview-4"
description: "Previous CogView-4 snapshot for compatibility"
description: "Legacy Z.AI image model on the same endpoint"
```
### Question 3: Default Quality
@@ -177,18 +177,21 @@ default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_image_api_dialect: null
default_model:
google: [selected google model or null]
openai: null
azure: [selected azure deployment or null]
openrouter: [selected openrouter model or null]
dashscope: null
zai: [selected Z.AI model or null]
minimax: [selected minimax model or null]
replicate: null
zai: [selected zai model or null]
---
```
If the user selects `OpenAI` but says their endpoint is only OpenAI-compatible and fronts another image model family, save `default_image_api_dialect: ratio-metadata` when they explicitly confirm the gateway expects aspect-ratio `size` plus metadata-based resolution. Otherwise leave it `null` / `openai-native`.
## 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.
@@ -213,10 +216,12 @@ options:
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-2 (Recommended)"
description: "Latest GPT Image model, flexible sizes up to 4K, high-fidelity image inputs"
- label: "gpt-image-1.5"
description: "Previous GPT Image model"
- label: "gpt-image-1"
description: "Previous generation GPT Image model"
description: "Earlier GPT Image model"
```
### Azure Deployment Selection
@@ -225,8 +230,10 @@ options:
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-2 (Recommended)"
description: "Use when your Azure deployment name matches the GPT Image 2 model"
- label: "gpt-image-1.5"
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"
```
@@ -264,6 +271,10 @@ options:
description: "Legacy Qwen model with five fixed output sizes"
- label: "qwen-image-plus"
description: "Legacy Qwen model, same current capability as qwen-image"
- label: "wan2.7-image-pro"
description: "Wan 2.7 Pro — supports up to 4K text-to-image and reference-image editing"
- label: "wan2.7-image"
description: "Wan 2.7 base — faster generation, up to 2K, supports reference-image editing"
- label: "z-image-turbo"
description: "Legacy DashScope model for compatibility"
- label: "z-image-ultra"
@@ -274,18 +285,41 @@ 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`.
- `wan2.7-image-pro` and `wan2.7-image` are the only DashScope models that accept `--ref`. Pick one of these when the user wants reference-image editing or multi-image fusion via DashScope.
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
### Z.AI Model Selection
```yaml
header: "Z.AI Model"
question: "Choose a default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Current flagship image model with better text rendering and poster layouts"
- label: "cogview-4-250304"
description: "Legacy model on the sync image endpoint"
```
Notes for Z.AI setup:
- Prefer `glm-image` for posters, diagrams, and Chinese/English text-heavy layouts.
- In `baoyu-image-gen`, Z.AI currently exposes text-to-image only; reference images are not wired for this provider.
- The sync Z.AI image API returns a downloadable image URL, which the runtime saves locally after download.
### 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"
- label: "google/nano-banana-2 (Recommended)"
description: "Current default for general Replicate image generation in baoyu-image-gen"
- label: "bytedance/seedream-4.5"
description: "Replicate Seedream 4.5 with validated local size/ref guardrails"
- label: "bytedance/seedream-5-lite"
description: "Replicate Seedream 5 Lite with validated local size/ref guardrails"
- label: "wan-video/wan-2.7-image-pro"
description: "Replicate Wan 2.7 Image Pro with 4K text-to-image support"
```
### MiniMax Model Selection
@@ -306,27 +340,6 @@ Notes for MiniMax setup:
- `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.
### Z.AI Model Selection
```yaml
header: "Z.AI Model"
question: "Choose a default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Latest GLM-Image; pixels round to multiples of 32 and cap at 2^22"
- label: "cogview-4-250304"
description: "Legacy CogView-4 snapshot with 16-pixel size stepping"
- label: "cogview-4"
description: "Earlier CogView-4 snapshot for compatibility"
```
Notes for Z.AI setup:
- Set `ZAI_API_KEY` (or legacy `BIGMODEL_API_KEY`) from https://docs.z.ai/.
- `glm-image` supports recommended aspect sizes (1280x1280, 1728x960, 1568x1056, …); uncommon ratios auto-fit to the 2^22 pixel budget on multiples of 32.
- Legacy CogView models use 16-pixel stepping and cap at 2^21 pixels per image.
- Z.AI does not accept reference images or `n > 1` in `baoyu-image-gen`; use Google/OpenAI providers for those workflows.
### Update EXTEND.md
After user selects a model:
@@ -342,9 +355,9 @@ default_model:
azure: [value or null]
openrouter: [value or null]
dashscope: [value or null]
zai: [value or null]
minimax: [value or null]
replicate: [value or null]
zai: [value or null]
```
Only set the selected provider's model; leave others as their current value or null.
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
---
version: 1
default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|zai|null (null = auto-detect)
default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k)
@@ -19,15 +19,17 @@ 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_image_api_dialect: null # openai-native|ratio-metadata|null (OpenAI-compatible gateways; null = use env/default)
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"
openai: null # e.g., "gpt-image-2", "gpt-image-1.5", "gpt-image-1"
azure: null # Azure deployment name, e.g., "gpt-image-2" or "image-prod"
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
dashscope: null # e.g., "qwen-image-2.0-pro"
zai: null # e.g., "glm-image"
minimax: null # e.g., "image-01"
replicate: null # e.g., "google/nano-banana-pro"
zai: null # e.g., "glm-image", "cogview-4-250304"
replicate: null # e.g., "google/nano-banana-2"
batch:
max_workers: 10
@@ -50,10 +52,10 @@ batch:
dashscope:
concurrency: 3
start_interval_ms: 1100
minimax:
zai:
concurrency: 3
start_interval_ms: 1100
zai:
minimax:
concurrency: 3
start_interval_ms: 1100
---
@@ -68,14 +70,15 @@ batch:
| `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_image_api_dialect` | string\|null | null | OpenAI-compatible image dialect (`openai-native` or `ratio-metadata`) |
| `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.zai` | string\|null | null | Z.AI default model |
| `default_model.minimax` | string\|null | null | MiniMax default model |
| `default_model.replicate` | string\|null | null | Replicate default model |
| `default_model.zai` | string\|null | null | Z.AI default model (glm-image / cogview-4-*) |
| `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 |
@@ -88,6 +91,7 @@ batch:
version: 1
default_provider: google
default_quality: 2k
default_image_api_dialect: null
---
```
@@ -99,15 +103,16 @@ default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_image_api_dialect: null
default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5"
azure: "gpt-image-1.5"
openai: "gpt-image-2"
azure: "gpt-image-2"
openrouter: "google/gemini-3.1-flash-image-preview"
dashscope: "qwen-image-2.0-pro"
minimax: "image-01"
replicate: "google/nano-banana-pro"
zai: "glm-image"
minimax: "image-01"
replicate: "google/nano-banana-2"
batch:
max_workers: 10
provider_limits:
@@ -117,14 +122,14 @@ batch:
azure:
concurrency: 3
start_interval_ms: 1100
zai:
concurrency: 3
start_interval_ms: 1100
openrouter:
concurrency: 3
start_interval_ms: 1100
minimax:
concurrency: 3
start_interval_ms: 1100
zai:
concurrency: 3
start_interval_ms: 1100
---
```
@@ -17,6 +17,17 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`,
- Default is `1664*928`
- `qwen-image` currently has the same capability as `qwen-image-plus`
**`wan2.7-image*`** — multimodal Wan 2.7 family. Members: `wan2.7-image-pro`, `wan2.7-image`.
- Free-form `size` in `宽*高` format, plus aspect-ratio inference
- `wan2.7-image-pro` text-to-image (no `--ref`): total pixels in `[768*768, 4096*4096]`, ratio in `[1:8, 8:1]`
- `wan2.7-image-pro` with reference images and `wan2.7-image` (all scenarios): total pixels in `[768*768, 2048*2048]`, ratio in `[1:8, 8:1]`
- Default: `1024*1024` (`--quality normal`) or `2048*2048` (`--quality 2k`); 4K requires explicit `--size`
- Supports up to 9 reference images in `--ref` (image editing / multi-image fusion)
- Reference images are sent inline as base64 (or passed through if the path is an `http(s)://` URL)
- API does NOT use `prompt_extend`; the skill omits it for this family
- The Wan 2.7 API defaults `n` to **4** in non-collage mode and bills per generated image. baoyu-image-gen forces `n: 1` and rejects `--n > 1` to avoid silently paying for and discarding extra images.
**Legacy**`z-image-turbo`, `z-image-ultra`, `wanx-v1`. Only use when the user explicitly asks for legacy behavior.
## Size Resolution
@@ -24,7 +35,8 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`,
- `--size` wins over `--ar`
- For `qwen-image-2.0*`: prefer explicit `--size`; otherwise infer from `--ar` using the recommended table below
- For `qwen-image-max/plus/image`: only use the five fixed sizes; if the requested ratio doesn't fit, switch to `qwen-image-2.0-pro`
- `--quality` is a baoyu-imagine preset, not an official DashScope field. The mapping of `normal`/`2k` onto the `qwen-image-2.0*` table is an implementation choice, not an API guarantee
- For `wan2.7-image*`: explicit `--size` is validated against the per-mode pixel/ratio limits; otherwise the size is derived from `--ar` and `--quality` (`normal` ≈ 1K, `2k` ≈ 2K). To request 4K with `wan2.7-image-pro` text-to-image, pass `--size` explicitly (e.g. `4096*4096`, `3840*2160`)
- `--quality` is a baoyu-image-gen preset, not an official DashScope field. The mapping of `normal`/`2k` onto the `qwen-image-2.0*` and `wan2.7-image*` tables is an implementation choice, not an API guarantee
### Recommended `qwen-image-2.0*` sizes
@@ -39,12 +51,19 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`,
| `16:9` | `1280*720` | `1920*1080` |
| `21:9` | `1344*576` | `2048*872` |
## Reference Images
- Only `wan2.7-image-pro` and `wan2.7-image` accept `--ref`. Other DashScope models (qwen-image-2.0*, qwen-image-max/plus/image, legacy) reject `--ref` and the user is steered to a different provider/model.
- Up to 9 reference images per request. Local files are inlined as base64 data URLs; `http(s)://` URLs are forwarded as-is.
- Supplying any `--ref` automatically clamps the wan2.7-image-pro pixel ceiling from 4K to 2K (the API only supports 4K for pure text-to-image with no image input).
## Not Exposed
DashScope APIs also support `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-imagine` does not expose them as CLI flags today.
DashScope APIs also support `negative_prompt`, `prompt_extend`, `watermark`, `thinking_mode`, `seed`, `bbox_list`, `enable_sequential`, and `color_palette`. `baoyu-image-gen` does not expose them as CLI flags today; the wan2.7 family relies on the API defaults (e.g. `thinking_mode=true`). The skill always sends `n=1` for wan2.7 — if you want grid/collage mode you currently need to call the API directly.
## 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)
- [Wan 2.7 image generation & editing API](https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference)
@@ -19,7 +19,7 @@ Read when the user picks `--provider minimax` or sets `default_model.minimax`. D
- `--ref` files are sent as MiniMax `subject_reference`
- `subject_reference[].type` is currently `character`
- Official docs say `image_file` supports public URLs or Base64 Data URLs; baoyu-imagine sends local refs as Data URLs
- Official docs say `image_file` supports public URLs or Base64 Data URLs; baoyu-image-gen sends local refs as Data URLs
- Recommended refs: front-facing portraits, JPG/JPEG/PNG, under 10MB
## Official References
@@ -1,6 +1,6 @@
# Replicate
Read when the user picks `--provider replicate`. Replicate support is intentionally scoped to model families baoyu-imagine can validate locally and save without dropping outputs.
Read when the user picks `--provider replicate`. Replicate support is intentionally scoped to model families baoyu-image-gen can validate locally and save without dropping outputs.
## Supported Families
@@ -37,7 +37,7 @@ Read when the user picks `--provider replicate`. Replicate support is intentiona
## Guardrails
- Replicate currently supports only single-output save semantics in this tool — keep `--n 1`
- If a model is outside the compatibility list above, baoyu-imagine treats it as prompt-only and rejects advanced local options instead of guessing a nano-banana-style schema
- If a model is outside the compatibility list above, baoyu-image-gen treats it as prompt-only and rejects advanced local options instead of guessing a nano-banana-style schema
## Examples
@@ -6,7 +6,7 @@ Read when the user picks `--provider zai` or sets `default_model.zai`. Default m
**`glm-image`** (recommended default)
- Text-to-image only in baoyu-imagine (no `--ref` support yet)
- Text-to-image only in baoyu-image-gen (no `--ref` support yet)
- Native `quality` options are `hd` and `standard`; this skill maps `2k → hd` and `normal → standard`
- Recommended sizes: `1280x1280`, `1568x1056`, `1056x1568`, `1472x1088`, `1088x1472`, `1728x960`, `960x1728`
- Custom `--size` requires width/height in `[1024, 2048]`, divisible by `32`, total pixels ≤ `2^22`
@@ -17,7 +17,7 @@ Read when the user picks `--provider zai` or sets `default_model.zai`. Default m
## Behavior Notes
- The sync API returns a temporary URL; baoyu-imagine downloads it and writes locally
- The sync API returns a temporary URL; baoyu-image-gen downloads it and writes locally
- `--ref` is not supported for Z.AI in this skill yet
- The sync API returns a single image, so `--n > 1` is rejected
@@ -25,10 +25,13 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref so
```bash
# OpenAI
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2
# Azure OpenAI (model = deployment name)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-2
# OpenAI GPT Image 2 custom 4K size
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic landscape" --image out.png --provider openai --model gpt-image-2 --size 3840x2160
# Google with explicit model
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
@@ -48,6 +51,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9
# DashScope legacy fixed-size
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
# DashScope Wan 2.7 Image Pro (4K text-to-image)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一间有着精致窗户的花店" --image out.png --provider dashscope --model wan2.7-image-pro --size 4096x4096
# DashScope Wan 2.7 Image with reference image (multi-image fusion)
${BUN_X} {baseDir}/scripts/main.ts --prompt "把图2的涂鸦喷绘在图1的汽车上" --image out.png --provider dashscope --model wan2.7-image-pro --ref car.webp paint.webp
# Z.AI GLM-image
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import test from "node:test";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
const scriptPath = path.join(repoRoot, "skills", "baoyu-image-gen", "scripts", "build-batch.ts");
async function makeFixture(): Promise<{
root: string;
outlinePath: string;
promptsDir: string;
outputPath: string;
}> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "baoyu-image-gen-build-batch-"));
const outlinePath = path.join(root, "outline.md");
const promptsDir = path.join(root, "prompts");
const outputPath = path.join(root, "batch.json");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(
outlinePath,
`## Illustration 1
**Position**: demo
**Purpose**: demo
**Visual Content**: demo
**Filename**: 01-demo.png
`,
);
await fs.writeFile(path.join(promptsDir, "01-demo.md"), "A demo prompt\n");
return { root, outlinePath, promptsDir, outputPath };
}
async function runBuildBatch(args: string[]): Promise<void> {
await execFileAsync(process.execPath, ["--import", "tsx", scriptPath, ...args], {
cwd: repoRoot,
});
}
test("build-batch omits default model so baoyu-image-gen can resolve env or EXTEND defaults", async () => {
const fixture = await makeFixture();
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(batch.tasks.length, 1);
assert.equal(batch.tasks[0]?.provider, "replicate");
assert.equal(Object.hasOwn(batch.tasks[0]!, "model"), false);
});
test("build-batch preserves explicit model overrides", async () => {
const fixture = await makeFixture();
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
"--model",
"acme/custom-model",
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(batch.tasks[0]?.model, "acme/custom-model");
});
test("build-batch propagates direct-usage references from prompt frontmatter", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
type: infographic
references:
- ref_id: 01
filename: 01-ref-brand.png
usage: direct
- ref_id: 02
filename: 02-ref-style.png
usage: style
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.deepEqual(batch.tasks[0]?.ref, ["references/01-ref-brand.png"]);
});
test("build-batch omits ref field when no direct references exist", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
references:
- ref_id: 01
filename: 01-ref-palette.png
usage: palette
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.equal(Object.hasOwn(batch.tasks[0]!, "ref"), false);
});
test("build-batch honors --refs-dir override", async () => {
const fixture = await makeFixture();
await fs.writeFile(
path.join(fixture.promptsDir, "01-demo.md"),
`---
illustration_id: 01
references:
- ref_id: 01
filename: brand.png
usage: direct
---
A demo prompt
`,
);
await runBuildBatch([
"--outline",
fixture.outlinePath,
"--prompts",
fixture.promptsDir,
"--output",
fixture.outputPath,
"--refs-dir",
"refs",
]);
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
tasks: Array<Record<string, unknown>>;
};
assert.deepEqual(batch.tasks[0]?.ref, ["refs/brand.png"]);
});
@@ -0,0 +1,238 @@
import path from "node:path";
import process from "node:process";
import { readdir, readFile, writeFile } from "node:fs/promises";
type CliArgs = {
outlinePath: string | null;
promptsDir: string | null;
outputPath: string | null;
imagesDir: string | null;
refsDir: string;
provider: string;
model: string | null;
aspectRatio: string;
quality: string;
jobs: number | null;
help: boolean;
};
type OutlineEntry = {
index: number;
filename: string;
};
type PromptReference = {
filename: string;
usage: "direct" | "style" | "palette";
};
function printUsage(): void {
console.log(`Usage:
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
Options:
--outline <path> Path to outline.md
--prompts <path> Path to prompts directory
--output <path> Path to output batch.json
--images-dir <path> Directory for generated images
--refs-dir <path> Directory holding reference images, relative to batch file (default: references)
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
--model <id> Explicit model for baoyu-image-gen batch tasks (default: resolved by baoyu-image-gen config/env)
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
--quality <level> Quality for all tasks (default: 2k)
--jobs <count> Recommended worker count metadata (optional)
-h, --help Show help`);
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
outlinePath: null,
promptsDir: null,
outputPath: null,
imagesDir: null,
refsDir: "references",
provider: "replicate",
model: null,
aspectRatio: "16:9",
quality: "2k",
jobs: null,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const current = argv[i]!;
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
else if (current === "--output") args.outputPath = argv[++i] ?? null;
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
else if (current === "--refs-dir") args.refsDir = argv[++i] ?? args.refsDir;
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
else if (current === "--model") args.model = argv[++i] ?? args.model;
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
else if (current === "--jobs") {
const value = argv[++i];
args.jobs = value ? parseInt(value, 10) : null;
} else if (current === "--help" || current === "-h") {
args.help = true;
}
}
return args;
}
function parsePromptReferences(content: string): PromptReference[] {
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
if (!fmMatch) return [];
const lines = fmMatch[1]!.split(/\r?\n/);
const refs: PromptReference[] = [];
let current: Partial<PromptReference> | null = null;
let inReferences = false;
let listIndent = 0;
const flush = () => {
if (current?.filename) {
refs.push({
filename: current.filename,
usage: (current.usage ?? "direct") as PromptReference["usage"],
});
}
current = null;
};
const unquote = (raw: string): string => raw.trim().replace(/^["']|["']$/g, "");
for (const line of lines) {
if (!line.trim() || line.trim().startsWith("#")) continue;
const keyMatch = line.match(/^(\S[^:]*):\s*(.*)$/);
if (keyMatch) {
flush();
if (keyMatch[1] === "references") {
inReferences = true;
listIndent = 0;
continue;
}
inReferences = false;
continue;
}
if (!inReferences) continue;
const itemMatch = line.match(/^(\s*)-\s*(.*)$/);
if (itemMatch) {
flush();
listIndent = itemMatch[1]!.length;
current = {};
const rest = itemMatch[2]!.trim();
if (rest) {
const kv = rest.match(/^(\w+)\s*:\s*(.*)$/);
if (kv && (kv[1] === "filename" || kv[1] === "usage")) {
(current as Record<string, string>)[kv[1]] = unquote(kv[2]!);
}
}
continue;
}
const kvMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)$/);
if (kvMatch && kvMatch[1]!.length > listIndent && current) {
if (kvMatch[2] === "filename" || kvMatch[2] === "usage") {
(current as Record<string, string>)[kvMatch[2]!] = unquote(kvMatch[3]!);
}
}
}
flush();
return refs;
}
function parseOutline(content: string): OutlineEntry[] {
const entries: OutlineEntry[] = [];
const blocks = content.split(/^## Illustration\s+/m).slice(1);
for (const block of blocks) {
const indexMatch = block.match(/^(\d+)/);
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
if (indexMatch && filenameMatch) {
entries.push({
index: parseInt(indexMatch[1]!, 10),
filename: filenameMatch[1]!.trim(),
});
}
}
return entries;
}
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
const files = await readdir(promptsDir);
const prefix = String(entry.index).padStart(2, "0");
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
return match ? path.join(promptsDir, match) : null;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
if (!args.outlinePath) {
console.error("Error: --outline is required");
process.exit(1);
}
if (!args.promptsDir) {
console.error("Error: --prompts is required");
process.exit(1);
}
if (!args.outputPath) {
console.error("Error: --output is required");
process.exit(1);
}
const outlineContent = await readFile(args.outlinePath, "utf8");
const entries = parseOutline(outlineContent);
if (entries.length === 0) {
console.error("No illustration entries found in outline.");
process.exit(1);
}
const tasks = [];
for (const entry of entries) {
const promptFile = await findPromptFile(args.promptsDir, entry);
if (!promptFile) {
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
continue;
}
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
const promptContent = await readFile(promptFile, "utf8");
const refs = parsePromptReferences(promptContent)
.filter((r) => r.usage === "direct")
.map((r) => path.posix.join(args.refsDir, r.filename));
const task: Record<string, unknown> = {
id: `illustration-${String(entry.index).padStart(2, "0")}`,
promptFiles: [promptFile],
image: path.join(imageDir, entry.filename),
provider: args.provider,
ar: args.aspectRatio,
quality: args.quality,
};
if (args.model) task.model = args.model;
if (refs.length > 0) task.ref = refs;
tasks.push(task);
}
const output: Record<string, unknown> = { tasks };
if (args.jobs) output.jobs = args.jobs;
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+218 -7
View File
@@ -13,10 +13,13 @@ import {
getWorkerCount,
isRetryableGenerationError,
loadBatchTasks,
loadExtendConfig,
mergeConfig,
normalizeOutputImagePath,
parseArgs,
parseOpenAIImageApiDialect,
parseSimpleYaml,
validateReferenceImages,
} from "./main.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
@@ -27,9 +30,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
provider: null,
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: null,
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -69,7 +75,7 @@ async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("parseArgs parses the main image-gen CLI flags", () => {
test("parseArgs parses the main baoyu-image-gen CLI flags", () => {
const args = parseArgs([
"--promptfiles",
"prompts/system.md",
@@ -77,11 +83,13 @@ test("parseArgs parses the main image-gen CLI flags", () => {
"--image",
"out/hero",
"--provider",
"openai",
"zai",
"--quality",
"2k",
"--imageSize",
"4k",
"--imageApiDialect",
"ratio-metadata",
"--ref",
"ref/one.png",
"ref/two.jpg",
@@ -94,9 +102,12 @@ test("parseArgs parses the main image-gen CLI flags", () => {
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
assert.equal(args.imagePath, "out/hero");
assert.equal(args.provider, "openai");
assert.equal(args.provider, "zai");
assert.equal(args.quality, "2k");
assert.equal(args.aspectRatioSource, null);
assert.equal(args.imageSize, "4K");
assert.equal(args.imageSizeSource, "cli");
assert.equal(args.imageApiDialect, "ratio-metadata");
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
assert.equal(args.n, 3);
assert.equal(args.jobs, 5);
@@ -113,6 +124,15 @@ test("parseArgs falls back to positional prompt and rejects invalid provider", (
);
});
test("validateReferenceImages can skip remote URLs for providers that support them", async () => {
await validateReferenceImages(["https://example.com/ref.png"], { allowRemoteUrls: true });
await assert.rejects(
() => validateReferenceImages(["https://example.com/ref.png"]),
/Reference image not found/,
);
});
test("parseSimpleYaml parses nested defaults and provider limits", () => {
const yaml = `
version: 2
@@ -120,9 +140,11 @@ default_provider: openrouter
default_quality: normal
default_aspect_ratio: '16:9'
default_image_size: 2K
default_image_api_dialect: ratio-metadata
default_model:
google: gemini-3-pro-image-preview
openai: gpt-image-1.5
openai: gpt-image-2
zai: glm-image
azure: image-prod
minimax: image-01
batch:
@@ -133,6 +155,9 @@ batch:
start_interval_ms: 900
openai:
concurrency: 4
zai:
concurrency: 2
start_interval_ms: 1000
minimax:
concurrency: 2
start_interval_ms: 1400
@@ -148,8 +173,10 @@ batch:
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_image_api_dialect, "ratio-metadata");
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?.openai, "gpt-image-2");
assert.equal(config.default_model?.zai, "glm-image");
assert.equal(config.default_model?.azure, "image-prod");
assert.equal(config.default_model?.minimax, "image-01");
assert.equal(config.batch?.max_workers, 8);
@@ -160,6 +187,10 @@ batch:
assert.deepEqual(config.batch?.provider_limits?.openai, {
concurrency: 4,
});
assert.deepEqual(config.batch?.provider_limits?.zai, {
concurrency: 2,
start_interval_ms: 1000,
});
assert.deepEqual(config.batch?.provider_limits?.minimax, {
concurrency: 2,
start_interval_ms: 1400,
@@ -170,6 +201,61 @@ batch:
});
});
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
const root = await makeTempDir("baoyu-image-gen-extend-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.mkdir(home, { recursive: true });
await fs.writeFile(legacyPath, `---
default_provider: google
default_quality: 2k
---
`);
const config = await loadExtendConfig(cwd, home);
assert.equal(config.default_provider, "google");
assert.equal(config.default_quality, "2k");
await fs.access(currentPath);
await assert.rejects(() => fs.access(legacyPath));
});
test("loadExtendConfig leaves legacy EXTEND.md untouched when both paths exist", async () => {
const root = await makeTempDir("baoyu-image-gen-extend-dual-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.mkdir(path.dirname(currentPath), { recursive: true });
await fs.mkdir(home, { recursive: true });
await fs.writeFile(legacyPath, `---
default_provider: google
---
`);
await fs.writeFile(currentPath, `---
default_provider: openai
---
`);
const config = await loadExtendConfig(cwd, home);
assert.equal(config.default_provider, "openai");
assert.equal(await fs.readFile(legacyPath, "utf8"), `---
default_provider: google
---
`);
assert.equal(await fs.readFile(currentPath, "utf8"), `---
default_provider: openai
---
`);
});
test("mergeConfig only fills values missing from CLI args", () => {
const merged = mergeConfig(
makeArgs({
@@ -183,13 +269,48 @@ test("mergeConfig only fills values missing from CLI args", () => {
default_quality: "2k",
default_aspect_ratio: "3:2",
default_image_size: "2K",
default_image_api_dialect: "ratio-metadata",
} satisfies Partial<ExtendConfig>,
);
assert.equal(merged.provider, "openai");
assert.equal(merged.quality, "2k");
assert.equal(merged.aspectRatio, "3:2");
assert.equal(merged.aspectRatioSource, "config");
assert.equal(merged.imageSize, "4K");
assert.equal(merged.imageSizeSource, "cli");
assert.equal(merged.imageApiDialect, "ratio-metadata");
});
test("mergeConfig tags inherited imageSize defaults so providers can ignore incompatible config", () => {
const merged = mergeConfig(
makeArgs(),
{
default_image_size: "2K",
} satisfies Partial<ExtendConfig>,
);
assert.equal(merged.imageSize, "2K");
assert.equal(merged.imageSizeSource, "config");
});
test("mergeConfig falls back to OPENAI_IMAGE_API_DIALECT when CLI and EXTEND are unset", (t) => {
useEnv(t, {
OPENAI_IMAGE_API_DIALECT: "ratio-metadata",
});
const merged = mergeConfig(makeArgs(), {});
assert.equal(merged.imageApiDialect, "ratio-metadata");
});
test("parseOpenAIImageApiDialect validates supported values", () => {
assert.equal(parseOpenAIImageApiDialect("openai-native"), "openai-native");
assert.equal(parseOpenAIImageApiDialect("ratio-metadata"), "ratio-metadata");
assert.equal(parseOpenAIImageApiDialect(null), null);
assert.throws(
() => parseOpenAIImageApiDialect("gateway-magic"),
/Invalid OpenAI image API dialect/,
);
});
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
@@ -197,7 +318,7 @@ test("detectProvider rejects non-ref-capable providers and prefers Google first
() =>
detectProvider(
makeArgs({
provider: "dashscope",
provider: "zai",
referenceImages: ["ref.png"],
}),
),
@@ -260,6 +381,27 @@ test("detectProvider selects Azure when only Azure credentials are configured",
);
});
test("detectProvider selects Z.AI when credentials are present 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,
ZAI_API_KEY: "zai-key",
BIGMODEL_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()), "zai");
assert.equal(detectProvider(makeArgs({ model: "glm-image" })), "zai");
});
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
@@ -294,6 +436,33 @@ test("detectProvider infers Seedream from model id and allows Seedream reference
);
});
test("detectProvider allows DashScope reference-image workflows when explicitly chosen for wan2.7 models", (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: "dashscope-key",
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({
provider: "dashscope",
model: "wan2.7-image-pro",
referenceImages: ["ref.png"],
}),
),
"dashscope",
);
});
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
@@ -319,6 +488,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
BAOYU_IMAGE_GEN_ZAI_CONCURRENCY: "4",
});
const extendConfig: Partial<ExtendConfig> = {
@@ -329,6 +499,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
concurrency: 2,
start_interval_ms: 900,
},
zai: {
concurrency: 1,
start_interval_ms: 1200,
},
minimax: {
concurrency: 1,
start_interval_ms: 1500,
@@ -342,6 +516,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
concurrency: 5,
startIntervalMs: 450,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).zai, {
concurrency: 4,
startIntervalMs: 1200,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
concurrency: 1,
startIntervalMs: 1500,
@@ -363,7 +541,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
id: "hero",
promptFiles: ["prompts/hero.md"],
image: "out/hero",
ref: ["refs/hero.png"],
ref: ["refs/hero.png", "https://example.com/ref.png"],
ar: "16:9",
},
],
@@ -379,6 +557,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
makeArgs({
provider: "replicate",
quality: "2k",
imageApiDialect: "ratio-metadata",
json: true,
}),
loaded.tasks[0]!,
@@ -391,10 +570,12 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
assert.deepEqual(taskArgs.referenceImages, [
path.join(loaded.batchDir, "refs/hero.png"),
"https://example.com/ref.png",
]);
assert.equal(taskArgs.provider, "replicate");
assert.equal(taskArgs.aspectRatio, "16:9");
assert.equal(taskArgs.quality, "2k");
assert.equal(taskArgs.imageApiDialect, "ratio-metadata");
assert.equal(taskArgs.json, true);
});
@@ -408,5 +589,35 @@ test("path normalization, worker count, and retry classification follow expected
assert.equal(getWorkerCount(5, 0, 4), 1);
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
assert.equal(
isRetryableGenerationError(
new Error("Replicate returned 2 outputs, but baoyu-image-gen currently supports saving exactly one image per request."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models accept at most 9 reference images. Received 10."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]."),
),
false,
);
assert.equal(
isRetryableGenerationError(
new Error("DashScope wan2.7-image requires total pixels between 768*768 and 2048*2048."),
),
false,
);
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
});
+168 -44
View File
@@ -2,12 +2,13 @@ import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import type {
BatchFile,
BatchTaskInput,
CliArgs,
ExtendConfig,
OpenAIImageApiDialect,
Provider,
} from "./types";
@@ -58,11 +59,11 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
openai: { concurrency: 3, startIntervalMs: 1100 },
openrouter: { concurrency: 3, startIntervalMs: 1100 },
dashscope: { concurrency: 3, startIntervalMs: 1100 },
zai: { concurrency: 3, startIntervalMs: 1100 },
minimax: { concurrency: 3, startIntervalMs: 1100 },
jimeng: { concurrency: 3, startIntervalMs: 1100 },
seedream: { concurrency: 3, startIntervalMs: 1100 },
azure: { concurrency: 3, startIntervalMs: 1100 },
zai: { concurrency: 3, startIntervalMs: 1100 },
};
function printUsage(): void {
@@ -77,14 +78,15 @@ Options:
--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|openrouter|dashscope|minimax|replicate|jimeng|seedream|azure|zai Force provider (auto-detect by default)
--provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
-m, --model <id> Model ID
--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 (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
--n <count> Number of images for the current task (default: 1)
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
--json JSON output
-h, --help Show help
@@ -97,7 +99,7 @@ Batch file format:
"promptFiles": ["prompts/hero.md"],
"image": "out/hero.png",
"provider": "replicate",
"model": "google/nano-banana-pro",
"model": "google/nano-banana-2",
"ar": "16:9"
}
]
@@ -107,6 +109,7 @@ Behavior:
- Batch mode automatically runs in parallel when pending tasks >= 2
- Each image retries automatically up to 3 attempts
- Batch summary reports success count, failure count, and per-image errors
- Replicate currently supports single-image save semantics only; --n must stay at 1
Environment variables:
OPENAI_API_KEY OpenAI API key
@@ -114,30 +117,33 @@ Environment variables:
GOOGLE_API_KEY Google API key
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
DASHSCOPE_API_KEY DashScope API key
ZAI_API_KEY Z.AI API key
BIGMODEL_API_KEY Backward-compatible alias for Z.AI API key
MINIMAX_API_KEY MiniMax API key
REPLICATE_API_TOKEN Replicate API token
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
ARK_API_KEY Seedream/Ark API key
ZAI_API_KEY Z.AI API key (alias: BIGMODEL_API_KEY)
BIGMODEL_API_KEY Z.AI API key alias (legacy BigModel credentials)
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-2)
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
BIGMODEL_IMAGE_MODEL Z.AI model alias (legacy BigModel variable)
OPENAI_BASE_URL Custom OpenAI endpoint
OPENAI_IMAGE_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
OPENROUTER_BASE_URL Custom OpenRouter endpoint
OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution
OPENROUTER_TITLE Optional app name for OpenRouter attribution
GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint
ZAI_BASE_URL Custom Z.AI endpoint
BIGMODEL_BASE_URL Backward-compatible alias for Z.AI endpoint
MINIMAX_BASE_URL Custom MiniMax endpoint
REPLICATE_BASE_URL Custom Replicate endpoint
JIMENG_BASE_URL Custom Jimeng endpoint
@@ -145,10 +151,8 @@ Environment variables:
AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-1.5)
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)
SEEDREAM_BASE_URL Custom Seedream endpoint
ZAI_BASE_URL Custom Z.AI endpoint (defaults to https://api.z.ai/api/paas/v4)
BIGMODEL_BASE_URL Z.AI endpoint alias (legacy BigModel variable)
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
@@ -164,9 +168,12 @@ export function parseArgs(argv: string[]): CliArgs {
provider: null,
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: null,
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -246,12 +253,12 @@ export function parseArgs(argv: string[]): CliArgs {
v !== "openai" &&
v !== "openrouter" &&
v !== "dashscope" &&
v !== "zai" &&
v !== "minimax" &&
v !== "replicate" &&
v !== "jimeng" &&
v !== "seedream" &&
v !== "azure" &&
v !== "zai"
v !== "azure"
) {
throw new Error(`Invalid provider: ${v}`);
}
@@ -270,6 +277,7 @@ export function parseArgs(argv: string[]): CliArgs {
const v = argv[++i];
if (!v) throw new Error("Missing value for --ar");
out.aspectRatio = v;
out.aspectRatioSource = "cli";
continue;
}
@@ -291,6 +299,16 @@ export function parseArgs(argv: string[]): CliArgs {
const v = argv[++i]?.toUpperCase();
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
out.imageSize = v;
out.imageSizeSource = "cli";
continue;
}
if (a === "--imageApiDialect") {
const v = argv[++i];
if (v !== "openai-native" && v !== "ratio-metadata") {
throw new Error(`Invalid imageApiDialect: ${v}`);
}
out.imageApiDialect = v;
continue;
}
@@ -397,18 +415,21 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
} else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
} else if (key === "default_image_api_dialect") {
config.default_image_api_dialect =
value === "null" ? null : parseOpenAIImageApiDialect(value);
} else if (key === "default_model") {
config.default_model = {
google: null,
openai: null,
openrouter: null,
dashscope: null,
zai: null,
minimax: null,
replicate: null,
jimeng: null,
seedream: null,
azure: null,
zai: null,
};
currentKey = "default_model";
currentProvider = null;
@@ -432,12 +453,12 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "openai" ||
key === "openrouter" ||
key === "dashscope" ||
key === "zai" ||
key === "minimax" ||
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure" ||
key === "zai"
key === "azure"
)
) {
config.batch ??= {};
@@ -451,12 +472,12 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "openai" ||
key === "openrouter" ||
key === "dashscope" ||
key === "zai" ||
key === "minimax" ||
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure" ||
key === "zai"
key === "azure"
)
) {
const cleaned = value.replace(/['"]/g, "");
@@ -482,14 +503,58 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
return config;
}
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
const home = homedir();
const cwd = process.cwd();
export function parseOpenAIImageApiDialect(
value: string | undefined | null
): OpenAIImageApiDialect | null {
if (!value) return null;
const normalized = value.replace(/['"]/g, "").trim();
if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;
throw new Error(`Invalid OpenAI image API dialect: ${value}`);
}
const paths = [
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
type ExtendConfigPathPair = {
current: string;
legacy: string;
};
function getExtendConfigPathPairs(cwd: string, home: string): ExtendConfigPathPair[] {
return [
{
current: path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
legacy: path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
},
{
current: path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
legacy: path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
},
];
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {
for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {
const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);
if (hasCurrent || !hasLegacy) continue;
await mkdir(path.dirname(current), { recursive: true });
await rename(legacy, current);
}
}
export async function loadExtendConfig(
cwd = process.cwd(),
home = homedir(),
): Promise<Partial<ExtendConfig>> {
await migrateLegacyExtendConfig(cwd, home);
const paths = getExtendConfigPathPairs(cwd, home).map(({ current }) => current);
for (const p of paths) {
try {
@@ -506,12 +571,25 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
}
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;
const imageSize = args.imageSize ?? extend.default_image_size ?? null;
const imageApiDialect =
args.imageApiDialect ??
extend.default_image_api_dialect ??
parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);
return {
...args,
provider: args.provider ?? extend.default_provider ?? null,
quality: args.quality ?? extend.default_quality ?? null,
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
imageSize: args.imageSize ?? extend.default_image_size ?? null,
aspectRatio,
aspectRatioSource:
args.aspectRatioSource ??
(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),
imageSize,
imageSizeSource:
args.imageSizeSource ??
(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),
imageApiDialect,
};
}
@@ -547,14 +625,14 @@ export function getConfiguredProviderRateLimits(
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
};
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure", "zai"] as Provider[]) {
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
configured[provider] = {
@@ -606,6 +684,7 @@ function inferProviderFromModel(model: string | null): Provider | null {
const normalized = model.trim();
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";
return null;
}
@@ -619,10 +698,11 @@ export function detectProvider(args: CliArgs): Provider {
args.provider !== "openrouter" &&
args.provider !== "replicate" &&
args.provider !== "seedream" &&
args.provider !== "minimax"
args.provider !== "minimax" &&
args.provider !== "dashscope"
) {
throw new Error(
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
);
}
@@ -633,11 +713,11 @@ export function detectProvider(args: CliArgs): Provider {
const hasOpenai = !!process.env.OPENAI_API_KEY;
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
const hasMinimax = !!process.env.MINIMAX_API_KEY;
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
const hasSeedream = !!process.env.ARK_API_KEY;
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
const modelProvider = inferProviderFromModel(args.model);
if (modelProvider === "seedream") {
@@ -654,6 +734,13 @@ export function detectProvider(args: CliArgs): Provider {
return "minimax";
}
if (modelProvider === "zai") {
if (!hasZai) {
throw new Error("Model looks like a Z.AI image model, but ZAI_API_KEY is not set.");
}
return "zai";
}
if (args.referenceImages.length > 0) {
if (hasGoogle) return "google";
if (hasOpenai) return "openai";
@@ -673,24 +760,40 @@ export function detectProvider(args: CliArgs): Provider {
hasAzure && "azure",
hasOpenrouter && "openrouter",
hasDashscope && "dashscope",
hasZai && "zai",
hasMinimax && "minimax",
hasReplicate && "replicate",
hasJimeng && "jimeng",
hasSeedream && "seedream",
hasZai && "zai",
].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!;
throw new Error(
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, ARK_API_KEY, or ZAI_API_KEY/BIGMODEL_API_KEY.\n" +
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
);
}
export async function validateReferenceImages(referenceImages: string[]): Promise<void> {
export type ReferenceImageValidationOptions = {
allowRemoteUrls?: boolean;
};
function isRemoteReferenceImage(refPath: string): boolean {
return /^https?:\/\//i.test(refPath);
}
function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {
return provider === "dashscope";
}
export async function validateReferenceImages(
referenceImages: string[],
options: ReferenceImageValidationOptions = {},
): Promise<void> {
for (const refPath of referenceImages) {
if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue;
const fullPath = path.resolve(refPath);
try {
await access(fullPath);
@@ -716,6 +819,12 @@ export function isRetryableGenerationError(error: unknown): boolean {
"API error (403)",
"API error (404)",
"temporarily disabled",
"supports saving exactly one image",
"supports only",
"support exactly one output image",
"support aspect ratios in",
"requires total pixels between",
"accept at most",
];
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
}
@@ -723,13 +832,13 @@ export function isRetryableGenerationError(error: unknown): boolean {
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
return (await import("./providers/openai")) as ProviderModule;
}
@@ -755,12 +864,12 @@ function getModelForProvider(
return extendConfig.default_model.openrouter;
}
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
}
return providerModule.getDefaultModel();
}
@@ -771,7 +880,11 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
if (!prompt) throw new Error("Prompt is required");
if (!args.imagePath) throw new Error("--image is required");
if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
if (args.referenceImages.length > 0) {
await validateReferenceImages(args.referenceImages, {
allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider),
});
}
const provider = detectProvider(args);
const providerModule = await loadProviderModule(provider);
@@ -820,6 +933,10 @@ export function resolveBatchPath(batchDir: string, filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
}
function resolveBatchReferencePath(batchDir: string, filePath: string): string {
return isRemoteReferenceImage(filePath) ? filePath : resolveBatchPath(batchDir, filePath);
}
export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
return {
...baseArgs,
@@ -829,10 +946,13 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir
provider: task.provider ?? baseArgs.provider ?? null,
model: task.model ?? baseArgs.model ?? null,
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),
size: task.size ?? baseArgs.size ?? null,
quality: task.quality ?? baseArgs.quality ?? null,
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [],
n: task.n ?? baseArgs.n,
batchFile: null,
jobs: baseArgs.jobs,
@@ -856,7 +976,11 @@ async function prepareBatchTasks(
const prompt = await loadPromptForArgs(taskArgs);
if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);
if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);
if (taskArgs.referenceImages.length > 0) await validateReferenceImages(taskArgs.referenceImages);
if (taskArgs.referenceImages.length > 0) {
await validateReferenceImages(taskArgs.referenceImages, {
allowRemoteUrls: shouldAllowRemoteReferenceImages(taskArgs.provider),
});
}
const provider = detectProvider(taskArgs);
const providerModule = await loadProviderModule(provider);
@@ -980,7 +1104,7 @@ async function runBatchTasks(
const acquireProvider = createProviderGate(providerRateLimits);
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream", "azure", "zai"] as Provider[]) {
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
const limit = providerRateLimits[provider];
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
}
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -46,7 +46,7 @@ export function getDefaultModel(): string {
}
}
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-2";
}
function getEndpoint(): AzureEndpoint {
@@ -2,15 +2,42 @@ import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import {
generateImage,
getDefaultModel,
getModelFamily,
getQwen2SizeFromAspectRatio,
getSizeFromAspectRatio,
getWan27SizeFromAspectRatio,
normalizeSize,
parseAspectRatio,
parseSize,
resolveSizeForModel,
} from "./dashscope.ts";
import type { CliArgs } from "../types.ts";
function makeCliArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: "dashscope",
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: "2k",
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
function useEnv(
t: TestContext,
@@ -51,9 +78,11 @@ test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
assert.equal(parseAspectRatio("-1:2"), null);
});
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, wan2.7, and legacy models", () => {
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
assert.equal(getModelFamily("wan2.7-image"), "wan27");
assert.equal(getModelFamily("wan2.7-image-pro"), "wan27");
assert.equal(getModelFamily("z-image-turbo"), "legacy");
assert.equal(getModelFamily("wanx-v1"), "legacy");
});
@@ -146,3 +175,218 @@ test("DashScope size normalization converts WxH into provider format", () => {
assert.equal(normalizeSize("1024x1024"), "1024*1024");
assert.equal(normalizeSize("2048*1152"), "2048*1152");
});
test("Wan 2.7 derives sizes that match the requested ratio at the chosen pixel budget", () => {
const square2k = getWan27SizeFromAspectRatio(null, "2k", 2048 * 2048);
const parsedSquare = parseSize(square2k);
assert.ok(parsedSquare);
assert.equal(parsedSquare.width, parsedSquare.height);
assert.ok(parsedSquare.width * parsedSquare.height <= 2048 * 2048);
const widescreen = getWan27SizeFromAspectRatio("16:9", "2k", 2048 * 2048);
const parsedWide = parseSize(widescreen);
assert.ok(parsedWide);
assert.ok(Math.abs(parsedWide.width / parsedWide.height - 16 / 9) < 0.05);
assert.ok(parsedWide.width * parsedWide.height <= 2048 * 2048);
const pro4k = getWan27SizeFromAspectRatio("16:9", "2k", 4096 * 4096);
const parsed4k = parseSize(pro4k);
assert.ok(parsed4k);
assert.ok(parsed4k.width * parsed4k.height > 2048 * 2048);
assert.ok(parsed4k.width * parsed4k.height <= 4096 * 4096);
});
test("Wan 2.7 rejects aspect ratios outside the [1:8, 8:1] range", () => {
assert.throws(
() => getWan27SizeFromAspectRatio("9:1", "2k", 2048 * 2048),
/1:8, 8:1/,
);
assert.throws(
() => getWan27SizeFromAspectRatio("1:9", "normal", 2048 * 2048),
/1:8, 8:1/,
);
});
test("Wan 2.7 derived sizes stay inside the boundary ratio limits after rounding", () => {
for (const ar of ["8:1", "1:8"]) {
const size = getWan27SizeFromAspectRatio(ar, "2k", 2048 * 2048);
const parsed = parseSize(size);
assert.ok(parsed);
const ratio = parsed.width / parsed.height;
assert.ok(ratio >= 1 / 8);
assert.ok(ratio <= 8);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
}
});
test("resolveSizeForModel routes wan2.7-image to the 2K-capped derivation", () => {
const size = resolveSizeForModel("wan2.7-image", {
size: null,
aspectRatio: "16:9",
quality: "2k",
});
const parsed = parseSize(size);
assert.ok(parsed);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
assert.ok(Math.abs(parsed.width / parsed.height - 16 / 9) < 0.05);
});
test("resolveSizeForModel allows wan2.7-image-pro 4K only when there are no reference images", () => {
assert.equal(
resolveSizeForModel("wan2.7-image-pro", {
size: "4096*4096",
aspectRatio: null,
quality: "2k",
}),
"4096*4096",
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "4096*4096",
aspectRatio: null,
quality: "2k",
referenceImages: ["a.png"],
}),
/total pixels between 768\*768 and 2048\*2048/,
);
const proWithRef = resolveSizeForModel("wan2.7-image-pro", {
size: null,
aspectRatio: "1:1",
quality: "2k",
referenceImages: ["a.png"],
});
const parsedRef = parseSize(proWithRef);
assert.ok(parsedRef);
assert.ok(parsedRef.width * parsedRef.height <= 2048 * 2048);
});
test("Wan 2.7 request body forces n=1 and omits prompt_extend / negative_prompt", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
const originalFetch = globalThis.fetch;
let capturedBody: any = null;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
output: {
choices: [
{
message: {
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
},
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
await generateImage("hello", "wan2.7-image-pro", makeCliArgs({ aspectRatio: "1:1" }));
assert.equal(capturedBody.model, "wan2.7-image-pro");
assert.deepEqual(Object.keys(capturedBody.parameters).sort(), ["n", "size", "watermark"]);
assert.equal(capturedBody.parameters.n, 1);
assert.equal(capturedBody.parameters.watermark, false);
assert.equal(typeof capturedBody.parameters.size, "string");
assert.ok(!("prompt_extend" in capturedBody.parameters));
assert.ok(!("negative_prompt" in capturedBody.parameters));
assert.deepEqual(capturedBody.input.messages[0].content, [{ text: "hello" }]);
});
test("Wan 2.7 request body forwards remote reference image URLs", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
const originalFetch = globalThis.fetch;
let capturedBody: any = null;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
output: {
choices: [
{
message: {
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
},
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
await generateImage(
"combine these",
"wan2.7-image-pro",
makeCliArgs({ referenceImages: ["https://example.com/ref.png"] }),
);
assert.deepEqual(capturedBody.input.messages[0].content, [
{ image: "https://example.com/ref.png" },
{ text: "combine these" },
]);
});
test("Wan 2.7 rejects --n > 1 to prevent silent multi-image billing", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
await assert.rejects(
() => generateImage("hi", "wan2.7-image-pro", makeCliArgs({ n: 2 })),
/support exactly one output image/,
);
});
test("resolveSizeForModel validates explicit wan2.7 sizes by pixel budget and ratio", () => {
assert.equal(
resolveSizeForModel("wan2.7-image-pro", {
size: "3840x2160",
aspectRatio: null,
quality: "2k",
}),
"3840*2160",
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "3840x2160",
aspectRatio: null,
quality: "2k",
referenceImages: ["a.png"],
}),
/total pixels between 768\*768 and 2048\*2048/,
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image", {
size: "4096x4096",
aspectRatio: null,
quality: "2k",
}),
/total pixels between 768\*768 and 2048\*2048/,
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "3072*256",
aspectRatio: null,
quality: "2k",
}),
/1:8, 8:1/,
);
});
@@ -1,6 +1,8 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs, Quality } from "../types";
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy";
type DashScopeModelSpec = {
family: DashScopeModelFamily;
@@ -19,6 +21,16 @@ const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
"2k": 1536 * 1536,
};
const MIN_WAN27_TOTAL_PIXELS = 768 * 768;
const MAX_WAN27_PRO_T2I_PIXELS = 4096 * 4096;
const MAX_WAN27_GENERAL_PIXELS = 2048 * 2048;
const WAN27_MAX_REFERENCE_IMAGES = 9;
const WAN27_TARGET_PIXELS: Record<Quality, number> = {
normal: 1024 * 1024,
"2k": 2048 * 2048,
};
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" },
@@ -73,6 +85,11 @@ const QWEN_FIXED_SPEC: DashScopeModelSpec = {
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
};
const WAN27_SPEC: DashScopeModelSpec = {
family: "wan27",
defaultSize: "2048*2048",
};
const LEGACY_SPEC: DashScopeModelSpec = {
family: "legacy",
defaultSize: "1536*1536",
@@ -88,12 +105,31 @@ const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
"qwen-image-plus": QWEN_FIXED_SPEC,
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
"qwen-image": QWEN_FIXED_SPEC,
"wan2.7-image-pro": WAN27_SPEC,
"wan2.7-image": WAN27_SPEC,
};
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
}
function getReferenceImageMime(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".bmp") return "image/bmp";
return "image/png";
}
async function loadReferenceImage(refPath: string): Promise<string> {
if (/^https?:\/\//i.test(refPath)) {
return refPath;
}
const fullPath = path.resolve(refPath);
const bytes = await readFile(fullPath);
return `data:${getReferenceImageMime(fullPath)};base64,${bytes.toString("base64")}`;
}
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
@@ -173,6 +209,10 @@ function roundToStep(value: number): number {
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
}
function floorToStep(value: number): number {
return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP);
}
function fitToPixelBudget(
width: number,
height: number,
@@ -220,6 +260,21 @@ function fitToPixelBudget(
return { width: roundedWidth, height: roundedHeight };
}
function clampWan27DerivedSizeToRatioBounds(
size: { width: number; height: number },
): { width: number; height: number } {
let { width, height } = size;
const ratio = width / height;
if (ratio > 8) {
width = floorToStep(height * 8);
} else if (ratio < 1 / 8) {
height = floorToStep(width * 8);
}
return { width, height };
}
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;
@@ -276,6 +331,77 @@ export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs[
return formatSize(fitted.width, fitted.height);
}
function isWan27ProModel(model: string): boolean {
return model.trim().toLowerCase() === "wan2.7-image-pro";
}
function getWan27MaxPixels(model: string, hasReferenceImages: boolean): number {
if (isWan27ProModel(model) && !hasReferenceImages) {
return MAX_WAN27_PRO_T2I_PIXELS;
}
return MAX_WAN27_GENERAL_PIXELS;
}
export function getWan27SizeFromAspectRatio(
ar: string | null,
quality: CliArgs["quality"],
maxPixels: number,
): string {
const normalizedQuality = normalizeQuality(quality);
const targetPixels = Math.min(WAN27_TARGET_PIXELS[normalizedQuality], maxPixels);
if (!ar) {
const side = roundToStep(Math.sqrt(targetPixels));
return formatSize(side, side);
}
const parsed = parseAspectRatio(ar);
if (!parsed) {
const side = roundToStep(Math.sqrt(targetPixels));
return formatSize(side, side);
}
const ratio = parsed.width / parsed.height;
if (ratio < 1 / 8 || ratio > 8) {
throw new Error(
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. Received "${ar}".`
);
}
const rawWidth = Math.sqrt(targetPixels * ratio);
const rawHeight = Math.sqrt(targetPixels / ratio);
const fitted = fitToPixelBudget(
rawWidth,
rawHeight,
MIN_WAN27_TOTAL_PIXELS,
maxPixels,
);
const bounded = clampWan27DerivedSizeToRatioBounds(fitted);
return formatSize(bounded.width, bounded.height);
}
function validateWan27Size(size: string, maxPixels: number, model: string): string {
const normalized = normalizeSize(size);
const parsed = validateSizeFormat(normalized);
const totalPixels = parsed.width * parsed.height;
if (totalPixels < MIN_WAN27_TOTAL_PIXELS || totalPixels > maxPixels) {
const limit = maxPixels === MAX_WAN27_PRO_T2I_PIXELS ? "4096*4096" : "2048*2048";
throw new Error(
`DashScope ${model} requires total pixels between 768*768 and ${limit} ` +
`for the current request. Received ${normalized} (${totalPixels} pixels).`
);
}
const ratio = parsed.width / parsed.height;
if (ratio < 1 / 8 || ratio > 8) {
throw new Error(
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. ` +
`Received ${normalized} (ratio ${ratio.toFixed(3)}).`
);
}
return normalized;
}
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
if (quality === "normal") {
console.warn(
@@ -331,9 +457,16 @@ function validateQwenFixedSize(size: string): string {
export function resolveSizeForModel(
model: string,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality"> & { referenceImages?: string[] },
): string {
const spec = getModelSpec(model);
const referenceCount = args.referenceImages?.length ?? 0;
if (spec.family === "wan27") {
const maxPixels = getWan27MaxPixels(model, referenceCount > 0);
if (args.size) return validateWan27Size(args.size, maxPixels, model);
return getWan27SizeFromAspectRatio(args.aspectRatio, args.quality, maxPixels);
}
if (args.size) {
if (spec.family === "qwen2") return validateQwen2Size(args.size);
@@ -357,6 +490,14 @@ function buildParameters(
family: DashScopeModelFamily,
size: string,
): Record<string, unknown> {
if (family === "wan27") {
return {
size,
n: 1,
watermark: false,
};
}
const parameters: Record<string, unknown> = {
prompt_extend: false,
size,
@@ -419,23 +560,44 @@ export async function generateImage(
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
if (args.referenceImages.length > 0) {
const spec = getModelSpec(model);
if (args.referenceImages.length > 0 && spec.family !== "wan27") {
throw new Error(
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
"Reference images are not supported with this DashScope model. Use a wan2.7 image model (--model wan2.7-image-pro or wan2.7-image), or switch to --provider google with a Gemini multimodal model."
);
}
if (args.referenceImages.length > WAN27_MAX_REFERENCE_IMAGES) {
throw new Error(
`DashScope wan2.7 image models accept at most ${WAN27_MAX_REFERENCE_IMAGES} reference images. Received ${args.referenceImages.length}.`
);
}
if (spec.family === "wan27" && args.n !== 1) {
throw new Error(
"DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request (extra images would be billed but discarded). Remove --n or use --n 1."
);
}
const spec = getModelSpec(model);
const size = resolveSizeForModel(model, args);
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
const content: Array<Record<string, unknown>> = [];
if (spec.family === "wan27" && args.referenceImages.length > 0) {
for (const refPath of args.referenceImages) {
content.push({ image: await loadReferenceImage(refPath) });
}
}
content.push({ text: prompt });
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
content,
},
],
},
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -1,14 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildOpenAIGenerationsBody,
extractImageFromResponse,
getDefaultModel,
getOpenAIAspectRatio,
getOpenAIImageApiDialect,
getOpenAIResolution,
getMimeType,
getOpenAISize,
getOrientationFromAspectRatio,
inferAspectRatioFromSize,
inferResolutionFromSize,
parseAspectRatio,
validateArgs,
} from "./openai.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: "2k",
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
assert.equal(getDefaultModel(), "gpt-image-2");
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
assert.equal(parseAspectRatio("wide"), null);
assert.equal(parseAspectRatio("0:1"), null);
@@ -18,6 +51,96 @@ test("OpenAI aspect-ratio parsing and size selection match model families", () =
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");
assert.equal(getOpenAISize("gpt-image-2", "16:9", "2k"), "2048x1152");
assert.equal(getOpenAISize("gpt-image-2", "9:16", "2k"), "1152x2048");
assert.equal(getOpenAISize("gpt-image-2", "4:3", "2k"), "2048x1536");
assert.equal(getOpenAISize("gpt-image-2", "2.35:1", "normal"), "1248x528");
assert.equal(inferAspectRatioFromSize("1536x1024"), "3:2");
assert.equal(inferResolutionFromSize("1536x1024"), "2K");
assert.equal(getOpenAIAspectRatio({ aspectRatio: null, size: "2048x1152" }), "16:9");
assert.equal(getOpenAIResolution({ imageSize: null, size: "2048x1152", quality: "normal" }), "2K");
assert.equal(getOrientationFromAspectRatio("16:9"), "landscape");
assert.equal(getOrientationFromAspectRatio("9:16"), "portrait");
assert.equal(getOrientationFromAspectRatio("1:1"), null);
assert.equal(getOpenAIImageApiDialect({ imageApiDialect: null }), "openai-native");
});
test("OpenAI generations body switches between native and ratio-metadata dialects", () => {
assert.deepEqual(
buildOpenAIGenerationsBody("Draw a skyline", "gpt-image-2", {
aspectRatio: "16:9",
size: null,
quality: "2k",
imageSize: null,
imageApiDialect: null,
}),
{
model: "gpt-image-2",
prompt: "Draw a skyline",
size: "2048x1152",
quality: "high",
},
);
assert.deepEqual(
buildOpenAIGenerationsBody("Draw a skyline", "gemini-3-pro-image-preview", {
aspectRatio: "16:9",
size: null,
quality: "2k",
imageSize: null,
imageApiDialect: "ratio-metadata",
}),
{
model: "gemini-3-pro-image-preview",
prompt: "Draw a skyline",
size: "16:9",
metadata: {
resolution: "2K",
orientation: "landscape",
},
},
);
assert.deepEqual(
buildOpenAIGenerationsBody("Draw a portrait", "gemini-3-pro-image-preview", {
aspectRatio: null,
size: "1152x2048",
quality: "normal",
imageSize: null,
imageApiDialect: "ratio-metadata",
}),
{
model: "gemini-3-pro-image-preview",
prompt: "Draw a portrait",
size: "9:16",
metadata: {
resolution: "2K",
orientation: "portrait",
},
},
);
});
test("OpenAI validates gpt-image-2 custom size constraints", () => {
assert.doesNotThrow(() =>
validateArgs("gpt-image-2", makeArgs({ size: "3840x2160" })),
);
assert.doesNotThrow(() =>
validateArgs("gpt-image-2-2026-04-21", makeArgs({ aspectRatio: "2.35:1" })),
);
assert.throws(
() => validateArgs("gpt-image-2", makeArgs({ size: "1024x576" })),
/total pixels/,
);
assert.throws(
() => validateArgs("gpt-image-2", makeArgs({ size: "1025x1024" })),
/multiples of 16px/,
);
assert.throws(
() => validateArgs("gpt-image-2", makeArgs({ aspectRatio: "4:1" })),
/must not exceed 3:1/,
);
});
test("OpenAI mime-type detection covers supported reference image extensions", () => {
@@ -1,9 +1,9 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import type { CliArgs, OpenAIImageApiDialect } from "../types";
export function getDefaultModel(): string {
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-2";
}
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
@@ -23,6 +23,57 @@ type SizeMapping = {
portrait: string;
};
type OpenAIGenerationsBody = Record<string, unknown>;
function isGptImageModel(model: string): boolean {
return model.includes("gpt-image");
}
function isGptImage2Model(model: string): boolean {
return model.includes("gpt-image-2");
}
function roundToMultiple(value: number, multiple: number): number {
return Math.max(multiple, Math.round(value / multiple) * multiple);
}
function buildGptImage2SizeFromAspectRatio(
ar: string | null,
quality: CliArgs["quality"],
): string {
const parsed = ar ? parseAspectRatio(ar) : null;
const ratio = parsed ? parsed.width / parsed.height : 1;
if (!parsed || Math.abs(ratio - 1) < 0.1) {
const edge = quality === "2k" ? 2048 : 1024;
return `${edge}x${edge}`;
}
const targetLongEdge = quality === "2k" ? 2048 : 1024;
let width: number;
let height: number;
if (ratio > 1) {
width = targetLongEdge;
height = roundToMultiple(width / ratio, 16);
} else {
height = targetLongEdge;
width = roundToMultiple(height * ratio, 16);
}
while (width * height < 655_360) {
if (ratio > 1) {
width += 16;
height = roundToMultiple(width / ratio, 16);
} else {
height += 16;
width = roundToMultiple(height * ratio, 16);
}
}
return `${width}x${height}`;
}
export function getOpenAISize(
model: string,
ar: string | null,
@@ -35,6 +86,10 @@ export function getOpenAISize(
return "1024x1024";
}
if (isGptImage2Model(model)) {
return buildGptImage2SizeFromAspectRatio(ar, quality);
}
const sizes: SizeMapping = isDalle3
? {
square: "1024x1024",
@@ -60,6 +115,166 @@ export function getOpenAISize(
return sizes.square;
}
function parsePixelSize(value: string): { width: number; height: number } | null {
const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y !== 0) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {
return args.imageApiDialect ?? "openai-native";
}
export function inferAspectRatioFromSize(size: string | null): string | null {
if (!size) return null;
const parsed = parsePixelSize(size);
if (!parsed) return null;
const divisor = gcd(parsed.width, parsed.height);
return `${parsed.width / divisor}:${parsed.height / divisor}`;
}
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
if (!size) return null;
const parsed = parsePixelSize(size);
if (!parsed) return null;
const longestEdge = Math.max(parsed.width, parsed.height);
if (longestEdge <= 1024) return "1K";
if (longestEdge <= 2048) return "2K";
return "4K";
}
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
}
export function getOpenAIResolution(
args: Pick<CliArgs, "imageSize" | "size" | "quality">
): "1K" | "2K" | "4K" {
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
return args.imageSize;
}
const inferred = inferResolutionFromSize(args.size);
if (inferred) return inferred;
return args.quality === "normal" ? "1K" : "2K";
}
function getOpenAIQuality(model: string, quality: CliArgs["quality"]): "standard" | "hd" | "medium" | "high" | null {
if (model.includes("dall-e-3")) {
return quality === "2k" ? "hd" : "standard";
}
if (isGptImageModel(model)) {
return quality === "2k" ? "high" : "medium";
}
return null;
}
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
const parsed = parseAspectRatio(ar);
if (!parsed) return null;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) return null;
return ratio > 1 ? "landscape" : "portrait";
}
export function buildOpenAIGenerationsBody(
prompt: string,
model: string,
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
): OpenAIGenerationsBody {
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
const aspectRatio = getOpenAIAspectRatio(args);
const metadata: Record<string, string> = {
resolution: getOpenAIResolution(args),
};
const orientation = getOrientationFromAspectRatio(aspectRatio);
if (orientation) metadata.orientation = orientation;
return {
model,
prompt,
size: aspectRatio,
metadata,
};
}
const body: OpenAIGenerationsBody = {
model,
prompt,
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
};
const quality = getOpenAIQuality(model, args.quality);
if (quality) {
body.quality = quality;
}
return body;
}
export function validateArgs(model: string, args: CliArgs): void {
if (!isGptImage2Model(model)) return;
if (args.aspectRatio && !args.size) {
const parsed = parseAspectRatio(args.aspectRatio);
if (!parsed) {
throw new Error(`Invalid gpt-image-2 aspect ratio: ${args.aspectRatio}`);
}
const ratio = parsed.width / parsed.height;
if (Math.max(ratio, 1 / ratio) > 3) {
throw new Error("gpt-image-2 aspect ratio must not exceed 3:1.");
}
}
if (!args.size) return;
const parsedSize = parsePixelSize(args.size);
if (!parsedSize) {
throw new Error(`Invalid gpt-image-2 --size: ${args.size}. Expected <width>x<height>.`);
}
const { width, height } = parsedSize;
const totalPixels = width * height;
const ratio = Math.max(width, height) / Math.min(width, height);
if (Math.max(width, height) > 3840) {
throw new Error("gpt-image-2 --size maximum edge length must be 3840px or less.");
}
if (width % 16 !== 0 || height % 16 !== 0) {
throw new Error("gpt-image-2 --size width and height must both be multiples of 16px.");
}
if (ratio > 3) {
throw new Error("gpt-image-2 --size long edge to short edge ratio must not exceed 3:1.");
}
if (totalPixels < 655_360 || totalPixels > 8_294_400) {
throw new Error("gpt-image-2 --size total pixels must be between 655,360 and 8,294,400.");
}
}
export async function generateImage(
prompt: string,
model: string,
@@ -78,18 +293,28 @@ export async function generateImage(
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
const imageApiDialect = getOpenAIImageApiDialect(args);
if (args.referenceImages.length > 0) {
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
if (imageApiDialect !== "openai-native") {
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)."
"Reference images are not supported with the ratio-metadata OpenAI dialect yet. Use openai-native, Google, Azure, OpenRouter, MiniMax, Seedream, or Replicate for image-edit workflows."
);
}
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-2 (or another gpt-image model)."
);
}
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
}
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
return generateWithOpenAIGenerations(
baseURL,
apiKey,
buildOpenAIGenerationsBody(prompt, model, args)
);
}
async function generateWithChatCompletions(
@@ -129,17 +354,8 @@ async function generateWithChatCompletions(
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
size: string,
quality: CliArgs["quality"]
body: OpenAIGenerationsBody
): 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: {
@@ -172,8 +388,9 @@ async function generateWithOpenAIEdits(
form.append("prompt", prompt);
form.append("size", size);
if (model.includes("gpt-image")) {
form.append("quality", quality === "2k" ? "high" : "medium");
const openAIQuality = getOpenAIQuality(model, quality);
if (openAIQuality && openAIQuality !== "standard" && openAIQuality !== "hd") {
form.append("quality", openAIQuality);
}
for (const refPath of referenceImages) {
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -5,7 +5,10 @@ import type { CliArgs } from "../types.ts";
import {
buildInput,
extractOutputUrl,
getDefaultModel,
getModelFamily,
parseModelId,
validateArgs,
} from "./replicate.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
@@ -16,9 +19,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
provider: null,
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: null,
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -29,10 +35,24 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
};
}
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
test("Replicate default model now points at nano-banana-2", () => {
const previous = process.env.REPLICATE_IMAGE_MODEL;
delete process.env.REPLICATE_IMAGE_MODEL;
try {
assert.equal(getDefaultModel(), "google/nano-banana-2");
} finally {
if (previous == null) {
delete process.env.REPLICATE_IMAGE_MODEL;
} else {
process.env.REPLICATE_IMAGE_MODEL = previous;
}
}
});
test("Replicate model parsing and family detection accept supported official ids", () => {
assert.deepEqual(parseModelId("google/nano-banana-2"), {
owner: "google",
name: "nano-banana-pro",
name: "nano-banana-2",
version: null,
});
assert.deepEqual(parseModelId("owner/model:abc123"), {
@@ -41,46 +61,224 @@ test("Replicate model parsing accepts official formats and rejects malformed one
version: "abc123",
});
assert.equal(getModelFamily("google/nano-banana-pro"), "nano-banana");
assert.equal(getModelFamily("bytedance/seedream-4.5"), "seedream45");
assert.equal(getModelFamily("bytedance/seedream-5-lite"), "seedream5lite");
assert.equal(getModelFamily("wan-video/wan-2.7-image"), "wan27image");
assert.equal(getModelFamily("wan-video/wan-2.7-image-pro"), "wan27imagepro");
assert.equal(getModelFamily("stability-ai/sdxl"), "unknown");
assert.throws(
() => parseModelId("just-a-model-name"),
/Invalid Replicate model format/,
);
});
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
test("Replicate nano-banana input builder maps refs, aspect ratio, and quality presets", () => {
assert.deepEqual(
buildInput(
"google/nano-banana-2",
"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",
aspect_ratio: "16:9",
image_input: ["data:image/png;base64,AAAA"],
},
);
assert.deepEqual(
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
buildInput(
"google/nano-banana-2",
"A robot painter",
makeArgs({ size: "1024x1024", quality: "normal" }),
[],
),
{
prompt: "A robot painter",
aspect_ratio: "match_input_image",
resolution: "1K",
output_format: "png",
image_input: ["ref"],
aspect_ratio: "1:1",
},
);
});
test("Replicate output extraction supports string, array, and object URLs", () => {
test("Replicate Seedream and Wan inputs use family-specific request fields", () => {
assert.deepEqual(
buildInput(
"bytedance/seedream-4.5",
"A cinematic portrait",
makeArgs({ quality: "2k", referenceImages: ["local.png"] }),
["data:image/png;base64,AAAA"],
),
{
prompt: "A cinematic portrait",
size: "4K",
image_input: ["data:image/png;base64,AAAA"],
aspect_ratio: "match_input_image",
},
);
assert.deepEqual(
buildInput(
"bytedance/seedream-4.5",
"A cinematic portrait",
makeArgs({ size: "1536x1024" }),
[],
),
{
prompt: "A cinematic portrait",
size: "custom",
width: 1536,
height: 1024,
},
);
assert.deepEqual(
buildInput(
"bytedance/seedream-5-lite",
"A poster",
makeArgs({ aspectRatio: "21:9", quality: "2k" }),
[],
),
{
prompt: "A poster",
size: "3K",
aspect_ratio: "21:9",
},
);
assert.deepEqual(
buildInput(
"wan-video/wan-2.7-image",
"A storyboard frame",
makeArgs({ aspectRatio: "16:9", quality: "2k" }),
[],
),
{
prompt: "A storyboard frame",
size: "2048*1152",
},
);
assert.deepEqual(
buildInput(
"wan-video/wan-2.7-image-pro",
"Blend these references",
makeArgs({ size: "2K", referenceImages: ["a.png", "b.png"] }),
["ref-a", "ref-b"],
),
{
prompt: "Blend these references",
size: "2K",
images: ["ref-a", "ref-b"],
},
);
});
test("Replicate validateArgs blocks misleading multi-output and unsupported family options locally", () => {
assert.throws(
() =>
validateArgs(
"google/nano-banana-2",
makeArgs({ n: 2 }),
),
/exactly one output image/,
);
assert.throws(
() =>
validateArgs(
"bytedance/seedream-4.5",
makeArgs({ size: "1K" }),
),
/2K, 4K, or an explicit WxH size/,
);
assert.throws(
() =>
validateArgs(
"bytedance/seedream-5-lite",
makeArgs({ size: "4K" }),
),
/supports 2K or 3K output/,
);
assert.throws(
() =>
validateArgs(
"wan-video/wan-2.7-image",
makeArgs({ referenceImages: new Array(10).fill("ref.png") }),
),
/at most 9 reference images/,
);
assert.throws(
() =>
validateArgs(
"wan-video/wan-2.7-image-pro",
makeArgs({ referenceImages: ["ref.png"], size: "4K" }),
),
/only supports 4K text-to-image/,
);
assert.throws(
() =>
validateArgs(
"stability-ai/sdxl",
makeArgs({ aspectRatio: "16:9" }),
),
/compatibility list/,
);
assert.doesNotThrow(() =>
validateArgs(
"google/nano-banana-2",
makeArgs({ imageSize: "2K", imageSizeSource: "config" }),
),
);
assert.throws(
() =>
validateArgs(
"google/nano-banana-2",
makeArgs({ imageSize: "2K", imageSizeSource: "cli" }),
),
/do not use --imageSize/,
);
assert.doesNotThrow(() =>
validateArgs(
"stability-ai/sdxl",
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "config" }),
),
);
assert.throws(
() =>
validateArgs(
"stability-ai/sdxl",
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "cli" }),
),
/compatibility list/,
);
assert.doesNotThrow(() =>
validateArgs(
"stability-ai/sdxl",
makeArgs(),
),
);
});
test("Replicate output extraction supports single outputs and rejects silent multi-image drops", () => {
assert.equal(
extractOutputUrl({ output: "https://example.com/a.png" } as never),
"https://example.com/a.png",
@@ -94,6 +292,17 @@ test("Replicate output extraction supports string, array, and object URLs", () =
"https://example.com/c.png",
);
assert.throws(
() =>
extractOutputUrl({
output: [
"https://example.com/one.png",
"https://example.com/two.png",
],
} as never),
/supports saving exactly one image/,
);
assert.throws(
() => extractOutputUrl({ output: { invalid: true } } as never),
/Unexpected Replicate output format/,
@@ -2,10 +2,37 @@ import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "google/nano-banana-pro";
const DEFAULT_MODEL = "google/nano-banana-2";
const SYNC_WAIT_SECONDS = 60;
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_MS = 300_000;
const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"5:4",
"4:5",
"9:16",
"16:9",
"21:9",
]);
export type ReplicateModelFamily =
| "nano-banana"
| "seedream45"
| "seedream5lite"
| "wan27image"
| "wan27imagepro"
| "unknown";
type PixelSize = {
width: number;
height: number;
};
type Seedream45Size = "2K" | "4K" | { width: number; height: number };
export function getDefaultModel(): string {
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
@@ -20,6 +47,40 @@ function getBaseUrl(): string {
return base.replace(/\/+$/g, "");
}
function normalizeModelId(model: string): string {
return model.trim().toLowerCase().split(":")[0]!;
}
export function getModelFamily(model: string): ReplicateModelFamily {
const normalized = normalizeModelId(model);
if (
normalized === "google/nano-banana" ||
normalized === "google/nano-banana-pro" ||
normalized === "google/nano-banana-2"
) {
return "nano-banana";
}
if (normalized === "bytedance/seedream-4.5") {
return "seedream45";
}
if (normalized === "bytedance/seedream-5-lite") {
return "seedream5lite";
}
if (normalized === "wan-video/wan-2.7-image") {
return "wan27image";
}
if (normalized === "wan-video/wan-2.7-image-pro") {
return "wan27imagepro";
}
return "unknown";
}
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
const [ownerName, version] = model.split(":");
const parts = ownerName!.split("/");
@@ -31,27 +92,219 @@ export function parseModelId(model: string): { owner: string; name: string; vers
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 };
function parsePixelSize(value: string): PixelSize | null {
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function parseAspectRatio(value: string): PixelSize | null {
const match = value.trim().match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y !== 0) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
function inferAspectRatioFromSize(size: string): string | null {
const parsed = parsePixelSize(size);
if (!parsed) return null;
const divisor = gcd(parsed.width, parsed.height);
const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;
if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {
return null;
}
return normalized;
}
function getQualityPreset(args: CliArgs): "normal" | "2k" {
return args.quality === "normal" ? "normal" : "2k";
}
function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {
if (aspectRatio === "match_input_image") {
return;
}
if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {
return;
}
throw new Error(
`Replicate model ${model} does not support aspect ratio ${aspectRatio}. Supported values: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
);
}
function getRequestedAspectRatio(model: string, args: CliArgs): string | null {
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
return args.aspectRatio;
}
if (!args.size) return null;
const inferred = inferAspectRatioFromSize(args.size);
if (!inferred) {
throw new Error(
`Replicate model ${model} cannot derive a supported aspect ratio from --size ${args.size}. Use one of: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
);
}
return inferred;
}
function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {
if (args.size) {
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate nano-banana --size must be in WxH format, for example 1536x1024.");
}
const longestEdge = Math.max(parsed.width, parsed.height);
if (longestEdge <= 1024) return "1K";
if (longestEdge <= 2048) return "2K";
throw new Error("Replicate nano-banana only supports sizes that map to 1K or 2K output.");
}
return getQualityPreset(args) === "normal" ? "1K" : "2K";
}
function resolveSeedream45Size(args: CliArgs): Seedream45Size {
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "2K" || upper === "4K") {
return upper;
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate Seedream 4.5 --size must be 2K, 4K, or an explicit WxH size.");
}
if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {
throw new Error("Replicate Seedream 4.5 custom --size must keep width and height between 1024 and 4096.");
}
return parsed;
}
return getQualityPreset(args) === "normal" ? "2K" : "4K";
}
function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "2K" || upper === "3K") {
return upper;
}
throw new Error("Replicate Seedream 5 Lite currently supports 2K or 3K output in this tool.");
}
return getQualityPreset(args) === "normal" ? "2K" : "3K";
}
function formatCustomWanSize(size: PixelSize): string {
return `${size.width}*${size.height}`;
}
function resolveWanSizeFromAspectRatio(
aspectRatio: string,
maxDimension: number,
): string {
const parsedRatio = parseAspectRatio(aspectRatio);
if (!parsedRatio) {
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${aspectRatio}.`);
}
const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);
const width = Math.max(1, Math.floor(parsedRatio.width * scale));
const height = Math.max(1, Math.floor(parsedRatio.height * scale));
return formatCustomWanSize({ width, height });
}
function resolveWanSize(family: "wan27image" | "wan27imagepro", args: CliArgs): "1K" | "2K" | "4K" | string {
const referenceMode = args.referenceImages.length > 0;
const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;
if (args.size) {
const upper = args.size.trim().toUpperCase();
if (upper === "1K" || upper === "2K" || upper === "4K") {
if (upper === "4K" && family !== "wan27imagepro") {
throw new Error("Replicate Wan 2.7 Image only supports 1K, 2K, or custom sizes up to 2048px.");
}
if (upper === "4K" && referenceMode) {
throw new Error("Replicate Wan 2.7 Image Pro only supports 4K text-to-image. Remove --ref or lower the size.");
}
return upper;
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("Replicate Wan --size must be 1K, 2K, 4K, or an explicit WxH size.");
}
if (parsed.width > maxDimension || parsed.height > maxDimension) {
throw new Error(
`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} custom --size must keep width and height at or below ${maxDimension}px in the current mode.`
);
}
return formatCustomWanSize(parsed);
}
if (args.aspectRatio) {
input.aspect_ratio = args.aspectRatio;
return resolveWanSizeFromAspectRatio(
args.aspectRatio,
getQualityPreset(args) === "normal" ? 1024 : 2048,
);
}
return getQualityPreset(args) === "normal" ? "1K" : "2K";
}
function buildNanoBananaInput(
prompt: string,
model: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const input: Record<string, unknown> = {
prompt,
resolution: getNanoBananaResolution(args),
output_format: "png",
};
const aspectRatio = getRequestedAspectRatio(model, args);
if (aspectRatio) {
input.aspect_ratio = aspectRatio;
} else if (referenceImages.length > 0) {
input.aspect_ratio = "match_input_image";
}
if (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;
}
@@ -59,6 +312,158 @@ export function buildInput(prompt: string, args: CliArgs, referenceImages: strin
return input;
}
function buildSeedreamInput(
family: "seedream45" | "seedream5lite",
prompt: string,
model: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);
const input: Record<string, unknown> = {
prompt,
};
if (family === "seedream45" && typeof size === "object") {
input.size = "custom";
input.width = size.width;
input.height = size.height;
} else {
input.size = size;
}
if (referenceImages.length > 0) {
input.image_input = referenceImages;
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
input.aspect_ratio = args.aspectRatio;
} else if (referenceImages.length > 0 && family === "seedream45") {
input.aspect_ratio = "match_input_image";
}
return input;
}
function buildWanInput(
family: "wan27image" | "wan27imagepro",
prompt: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const input: Record<string, unknown> = {
prompt,
size: resolveWanSize(family, args),
};
if (referenceImages.length > 0) {
input.images = referenceImages;
}
return input;
}
export function validateArgs(model: string, args: CliArgs): void {
parseModelId(model);
if (args.n !== 1) {
throw new Error("Replicate integration currently supports exactly one output image per request. Remove --n or use --n 1.");
}
if (args.imageSize && args.imageSizeSource !== "config") {
throw new Error("Replicate models in baoyu-image-gen do not use --imageSize. Use --quality, --ar, or --size instead.");
}
const family = getModelFamily(model);
if (family === "nano-banana") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate nano-banana supports at most 14 reference images.");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
if (args.size) {
getRequestedAspectRatio(model, args);
getNanoBananaResolution(args);
}
return;
}
if (family === "seedream45") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate Seedream 4.5 supports at most 14 reference images.");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
resolveSeedream45Size(args);
return;
}
if (family === "seedream5lite") {
if (args.referenceImages.length > 14) {
throw new Error("Replicate Seedream 5 Lite supports at most 14 reference images.");
}
if (args.aspectRatio) {
validateDocumentedAspectRatio(model, args.aspectRatio);
}
resolveSeedream5LiteSize(args);
return;
}
if (family === "wan27image" || family === "wan27imagepro") {
if (args.referenceImages.length > 9) {
throw new Error("Replicate Wan 2.7 image models support at most 9 reference images.");
}
if (args.aspectRatio) {
const parsed = parseAspectRatio(args.aspectRatio);
if (!parsed) {
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${args.aspectRatio}.`);
}
}
resolveWanSize(family, args);
return;
}
const hasExplicitAspectRatio = !!args.aspectRatio && args.aspectRatioSource !== "config";
if (args.referenceImages.length > 0 || hasExplicitAspectRatio || args.size) {
throw new Error(
`Replicate model ${model} is not in the baoyu-image-gen compatibility list. Supported families: google/nano-banana*, bytedance/seedream-4.5, bytedance/seedream-5-lite, wan-video/wan-2.7-image, wan-video/wan-2.7-image-pro.`
);
}
}
export function getDefaultOutputExtension(model: string): ".png" {
const _family = getModelFamily(model);
return ".png";
}
export function buildInput(
model: string,
prompt: string,
args: CliArgs,
referenceImages: string[],
): Record<string, unknown> {
const family = getModelFamily(model);
if (family === "nano-banana") {
return buildNanoBananaInput(prompt, model, args, referenceImages);
}
if (family === "seedream45" || family === "seedream5lite") {
return buildSeedreamInput(family, prompt, model, args, referenceImages);
}
if (family === "wan27image" || family === "wan27imagepro") {
return buildWanInput(family, prompt, args, referenceImages);
}
return { prompt };
}
async function readImageAsDataUrl(p: string): Promise<string> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
@@ -150,6 +555,11 @@ export function extractOutputUrl(prediction: PredictionResponse): string {
if (typeof output === "string") return output;
if (Array.isArray(output)) {
if (output.length !== 1) {
throw new Error(
`Replicate returned ${output.length} outputs, but baoyu-image-gen currently supports saving exactly one image per request.`
);
}
const first = output[0];
if (typeof first === "string") return first;
}
@@ -178,13 +588,14 @@ export async function generateImage(
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
const parsedModel = parseModelId(model);
validateArgs(model, args);
const refDataUrls: string[] = [];
for (const refPath of args.referenceImages) {
refDataUrls.push(await readImageAsDataUrl(refPath));
}
const input = buildInput(prompt, args, refDataUrls);
const input = buildInput(model, prompt, args, refDataUrls);
console.log(`Generating image with Replicate (${model})...`);
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
+9 -3
View File
@@ -3,13 +3,14 @@ export type Provider =
| "openai"
| "openrouter"
| "dashscope"
| "zai"
| "minimax"
| "replicate"
| "jimeng"
| "seedream"
| "azure"
| "zai";
| "azure";
export type Quality = "normal" | "2k";
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
export type CliArgs = {
prompt: string | null;
@@ -18,9 +19,12 @@ export type CliArgs = {
provider: Provider | null;
model: string | null;
aspectRatio: string | null;
aspectRatioSource?: "cli" | "task" | "config" | null;
size: string | null;
quality: Quality | null;
imageSize: string | null;
imageSizeSource?: "cli" | "task" | "config" | null;
imageApiDialect: OpenAIImageApiDialect | null;
referenceImages: string[];
n: number;
batchFile: string | null;
@@ -40,6 +44,7 @@ export type BatchTaskInput = {
size?: string | null;
quality?: Quality | null;
imageSize?: "1K" | "2K" | "4K" | null;
imageApiDialect?: OpenAIImageApiDialect | null;
ref?: string[];
n?: number;
};
@@ -57,17 +62,18 @@ export type ExtendConfig = {
default_quality: Quality | null;
default_aspect_ratio: string | null;
default_image_size: "1K" | "2K" | "4K" | null;
default_image_api_dialect: OpenAIImageApiDialect | null;
default_model: {
google: string | null;
openai: string | null;
openrouter: string | null;
dashscope: string | null;
zai: string | null;
minimax: string | null;
replicate: string | null;
jimeng: string | null;
seedream: string | null;
azure: string | null;
zai: string | null;
};
batch?: {
max_workers?: number | null;