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
+5 -5
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.119.0"
"version": "2.0.0"
},
"plugins": [
{
@@ -24,7 +24,7 @@
"./skills/baoyu-diagram",
"./skills/baoyu-electron-extract",
"./skills/baoyu-format-markdown",
"./skills/baoyu-imagine",
"./skills/baoyu-image-gen",
"./skills/baoyu-infographic",
"./skills/baoyu-markdown-to-html",
"./skills/baoyu-post-to-weibo",
@@ -33,9 +33,9 @@
"./skills/baoyu-slide-deck",
"./skills/baoyu-translate",
"./skills/baoyu-url-to-markdown",
"./skills/baoyu-image-cards",
"./skills/baoyu-youtube-transcript",
"./skills/baoyu-wechat-summary"
"./skills/baoyu-wechat-summary",
"./skills/baoyu-xhs-images",
"./skills/baoyu-youtube-transcript"
]
}
]
+11
View File
@@ -2,6 +2,17 @@
English | [中文](./CHANGELOG.zh.md)
## 2.0.0 - 2026-05-24
### Breaking
- Removed `baoyu-imagine` skill. All functionality (providers, scripts, references) now lives under `baoyu-image-gen`. The skill is registered in `marketplace.json` under the new name and its `homepage` URL has changed to `#baoyu-image-gen`.
- Removed `baoyu-image-cards` skill. All functionality (styles, layouts, palettes, presets) now lives under `baoyu-xhs-images`. The skill is registered in `marketplace.json` under the new name.
- Cross-skill `## Image Generation Tools` examples in `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-infographic`, and `baoyu-slide-deck` now reference `baoyu-image-gen` instead of `baoyu-imagine`.
### Migration
- Existing `~/.baoyu-skills/baoyu-imagine/EXTEND.md` and `.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`.
- Users invoking the skill via slash command should switch from `/baoyu-imagine ...` to `/baoyu-image-gen ...` and from any `baoyu-image-cards` reference to `baoyu-xhs-images`.
## 1.119.0 - 2026-05-24
### Features
+11
View File
@@ -2,6 +2,17 @@
[English](./CHANGELOG.md) | 中文
## 2.0.0 - 2026-05-24
### 破坏性变更
- 移除 `baoyu-imagine` skill。所有功能(providers、脚本、references)合并入 `baoyu-image-gen``marketplace.json` 改用新名称注册,`homepage` 链接更新为 `#baoyu-image-gen`
- 移除 `baoyu-image-cards` skill。所有功能(样式、布局、配色、预设)合并入 `baoyu-xhs-images``marketplace.json` 改用新名称注册
- 其它 skill`baoyu-article-illustrator``baoyu-comic``baoyu-cover-image``baoyu-infographic``baoyu-slide-deck`)中 `## Image Generation Tools` 示例统一改用 `baoyu-image-gen`,不再引用 `baoyu-imagine`
### 迁移说明
- 旧的 `~/.baoyu-skills/baoyu-imagine/EXTEND.md``.baoyu-skills/baoyu-imagine/EXTEND.md` 配置文件会被 `scripts/main.ts` 中的 legacy-path 解析器自动重命名到 `…/baoyu-image-gen/EXTEND.md`,首次运行时迁移
- 通过斜杠命令调用时,请将 `/baoyu-imagine ...` 改为 `/baoyu-image-gen ...`;将所有 `baoyu-image-cards` 改为 `baoyu-xhs-images`
## 1.119.0 - 2026-05-24
### 新功能
+3 -10
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.107.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **2.0.0**.
## Architecture
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
- **Image generation APIs**: `baoyu-imagine` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
## Security
@@ -64,7 +64,7 @@ Skills that prompt users for choices MUST declare the tool-selection convention
## Image Generation Tools
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-imagine`, `baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
### `codex-imagegen` Backend
@@ -82,13 +82,6 @@ Invoke via:
Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md. Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md).
## Deprecated Skills
| Skill | Note |
|-------|------|
| `baoyu-image-gen` | Superseded by `baoyu-imagine`. Not in `.claude-plugin/marketplace.json`. Kept functional — sync any cross-cutting changes with `baoyu-imagine`. |
| `baoyu-xhs-images` | Superseded by `baoyu-image-cards`. Not in `.claude-plugin/marketplace.json`. Kept functional — sync any cross-cutting changes with `baoyu-image-cards`. Do NOT update README for this skill. |
## Release Process
Use `/release-skills` workflow. Never skip:
+23 -23
View File
@@ -34,7 +34,7 @@ This repository now supports publishing each `skills/baoyu-*` directory as an in
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
```bash
clawhub install baoyu-imagine
clawhub install baoyu-image-gen
clawhub install baoyu-markdown-to-html
```
@@ -726,67 +726,67 @@ Post content to Weibo (微博). Supports regular posts with text, images, and vi
AI-powered generation backends.
#### baoyu-imagine
#### baoyu-image-gen
AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), MiniMax, Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, custom sizes, batch generation, and quality presets.
```bash
# Basic generation (auto-detect provider)
/baoyu-imagine --prompt "A cute cat" --image cat.png
/baoyu-image-gen --prompt "A cute cat" --image cat.png
# With aspect ratio
/baoyu-imagine --prompt "A landscape" --image landscape.png --ar 16:9
/baoyu-image-gen --prompt "A landscape" --image landscape.png --ar 16:9
# High quality (2k)
/baoyu-imagine --prompt "A banner" --image banner.png --quality 2k
/baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
# Specific provider
/baoyu-imagine --prompt "A cat" --image cat.png --provider openai --model gpt-image-2
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai --model gpt-image-2
# Azure OpenAI (model = deployment name)
/baoyu-imagine --prompt "A cat" --image cat.png --provider azure --model gpt-image-2
/baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-2
# OpenRouter
/baoyu-imagine --prompt "A cat" --image cat.png --provider openrouter
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
# OpenRouter with reference images
/baoyu-imagine --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
/baoyu-image-gen --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
# DashScope (Aliyun Tongyi Wanxiang)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# DashScope with custom size
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
/baoyu-image-gen --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# Z.AI GLM-Image
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
/baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
# MiniMax
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
/baoyu-image-gen --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
# MiniMax with subject reference
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
/baoyu-image-gen --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate (default: google/nano-banana-2)
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
# Replicate Seedream 4.5
/baoyu-imagine --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
/baoyu-image-gen --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro
/baoyu-imagine --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
/baoyu-image-gen --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
# Jimeng (即梦)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
# Seedream (豆包)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0)
/baoyu-imagine --prompt "Make it blue" --image out.png --ref source.png
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
# Batch mode
/baoyu-imagine --batchfile batch.json --jobs 4 --json
/baoyu-image-gen --batchfile batch.json --jobs 4 --json
```
**Options**:
@@ -865,7 +865,7 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
- Jimeng does not support reference images.
- Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0.
- Replicate defaults to `google/nano-banana-2`. `baoyu-imagine` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`.
- Replicate defaults to `google/nano-banana-2`. `baoyu-image-gen` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`.
- Replicate currently saves exactly one output image per request. `--n > 1` is blocked locally instead of silently dropping extra results.
- Replicate model behavior is family-specific: nano-banana uses `--quality` / `--ar`, Seedream uses validated `--size` / `--ar`, and Wan uses validated `--size` (with `--ar` converted locally to a concrete size).
@@ -1198,7 +1198,7 @@ Extract resources and JavaScript from any installed Electron app's `app.asar`. W
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
**Load Priority** (higher priority overrides lower):
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
2. `process.env` (system environment)
3. `<cwd>/.baoyu-skills/.env` (project-level)
4. `~/.baoyu-skills/.env` (user-level)
+23 -23
View File
@@ -34,7 +34,7 @@ npx skills add jimliu/baoyu-skills
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
```bash
clawhub install baoyu-imagine
clawhub install baoyu-image-gen
clawhub install baoyu-markdown-to-html
```
@@ -717,67 +717,67 @@ accounts:
AI 驱动的生成后端。
#### baoyu-imagine
#### baoyu-image-gen
基于 AI SDK 的图像生成,支持 OpenAI GPT Image 2、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。
```bash
# 基础生成(自动检测服务商)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png
# 指定宽高比
/baoyu-imagine --prompt "风景图" --image landscape.png --ar 16:9
/baoyu-image-gen --prompt "风景图" --image landscape.png --ar 16:9
# 高质量(2k 分辨率)
/baoyu-imagine --prompt "横幅图" --image banner.png --quality 2k
/baoyu-image-gen --prompt "横幅图" --image banner.png --quality 2k
# 指定服务商
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openai --model gpt-image-2
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai --model gpt-image-2
# Azure OpenAImodel 为部署名称)
/baoyu-imagine --prompt "一只猫" --image cat.png --provider azure --model gpt-image-2
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-2
# OpenRouter
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openrouter
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
# OpenRouter + 参考图
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
# DashScope(阿里通义万相)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# DashScope 自定义尺寸
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
/baoyu-image-gen --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# Z.AI GLM-Image
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
/baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
# MiniMax
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
/baoyu-image-gen --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
# MiniMax + 角色参考图
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
/baoyu-image-gen --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate(默认:google/nano-banana-2
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
# Replicate Seedream 4.5
/baoyu-imagine --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
/baoyu-image-gen --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro
/baoyu-imagine --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
/baoyu-image-gen --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
# 即梦(Jimeng
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
# 豆包(Seedream
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --ref source.png
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
# 批量模式
/baoyu-imagine --batchfile batch.json --jobs 4 --json
/baoyu-image-gen --batchfile batch.json --jobs 4 --json
```
**选项**
@@ -856,7 +856,7 @@ AI 驱动的生成后端。
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
- 即梦不支持参考图。
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
- Replicate 默认模型改为 `google/nano-banana-2``baoyu-imagine` 目前只对 `google/nano-banana*``bytedance/seedream-4.5``bytedance/seedream-5-lite``wan-video/wan-2.7-image``wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。
- Replicate 默认模型改为 `google/nano-banana-2``baoyu-image-gen` 目前只对 `google/nano-banana*``bytedance/seedream-4.5``bytedance/seedream-5-lite``wan-video/wan-2.7-image``wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。
- Replicate 当前只保存单张输出图,`--n > 1` 会在本地直接报错,避免多图结果被静默丢弃。
- Replicate 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`Seedream 走校验后的 `--size` / `--ar`Wan 走校验后的 `--size``--ar` 会先在本地换算成具体尺寸)。
@@ -1189,7 +1189,7 @@ AI 驱动的生成后端。
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
**加载优先级**(高优先级覆盖低优先级):
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-imagine ...`
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`
2. `process.env`(系统环境变量)
3. `<cwd>/.baoyu-skills/.env`(项目级)
4. `~/.baoyu-skills/.env`(用户级)
+1 -1
View File
@@ -24,7 +24,7 @@ This backend implements the `preferred_image_backend: codex-imagegen` config key
| Scenario | Conventional backend | This backend |
|----------|---------------------|--------------|
| You have a Codex subscription | OpenAI Images API costs add up per image | Subscription already covers it — zero marginal API cost |
| No `OPENAI_API_KEY` available | `baoyu-imagine` needs an API key | `codex login` is enough |
| No `OPENAI_API_KEY` available | `baoyu-image-gen` needs an API key | `codex login` is enough |
| Want to use GPT Image 2 | Only via OpenAI API | Codex's `image_gen` *is* GPT Image 2 |
## Prerequisites
+2 -2
View File
@@ -165,11 +165,11 @@ Standard snippet (copy verbatim):
When this skill needs to render an image:
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Hermes `image_generate`, `baoyu-imagine`, or any equivalent the user has installed.
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Hermes `image_generate`, `baoyu-image-gen`, or any equivalent the user has installed.
- **If multiple are available**, ask the user **once** at the start which to use (batch with any other initial questions).
- **If none are available**, tell the user and ask how to proceed.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
```
+6 -6
View File
@@ -1,6 +1,6 @@
# Image Generation Tools
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Hermes `image_generate`), others rely on an installed skill (`baoyu-imagine`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Hermes `image_generate`), others rely on an installed skill (`baoyu-image-gen`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
## The Rule
@@ -9,9 +9,9 @@ When a skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
2. **Saved preference** — if the skill's `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
4. **If none are available**, tell the user and ask how to proceed.
@@ -27,7 +27,7 @@ Each image-consuming skill's `EXTEND.md` carries a single `preferred_image_backe
|---|---|
| `auto` (default) | Apply the auto-select rule — runtime-native preferred, fall back to only installed backend, ask if multiple non-native. |
| `ask` | Always confirm the backend on every run, even when a runtime-native tool exists. |
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
The field is **absent-equals-auto**: older `EXTEND.md` files without this field behave exactly as if `preferred_image_backend: auto` were set. No schema version bump is needed to introduce it.
@@ -41,8 +41,8 @@ Each `SKILL.md` that renders images includes **exactly one** `## Image Generatio
Each skill's `references/config/preferences-schema.md` (and its `EXTEND.md` template in `first-time-setup.md`) lists `preferred_image_backend` alongside other preference fields. First-time setup does NOT ask the user about the backend — `auto` is set silently. Users who want to pin a specific backend edit `EXTEND.md` later, and each skill's `## Changing Preferences` section documents the common one-line edits.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
## Backend Skills Are Exempt
Skills that **are themselves** image-generation backends — currently `baoyu-imagine`, `baoyu-image-gen` (deprecated), and `baoyu-danger-gemini-web` — do NOT include a `## Image Generation Tools` section. They render directly via their own provider integrations and have no need to "select a backend." The rule applies only to consumer skills that delegate rendering to whatever backend the runtime exposes.
Skills that **are themselves** image-generation backends — currently `baoyu-image-gen`, `baoyu-image-gen` (deprecated), and `baoyu-danger-gemini-web` — do NOT include a `## Image Generation Tools` section. They render directly via their own provider integrations and have no need to "select a backend." The rule applies only to consumer skills that delegate rendering to whatever backend the runtime exposes.
+1 -1
View File
@@ -28,7 +28,7 @@ Skills that require image generation MUST delegate to available image generation
5. On failure, auto-retry once before reporting error
```
**Batch Parallel** (`baoyu-imagine` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
**Batch Parallel** (`baoyu-image-gen` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
## Output Path Convention
+6 -6
View File
@@ -1,7 +1,7 @@
---
name: baoyu-article-illustrator
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
version: 1.59.0
version: 1.59.1
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
@@ -28,9 +28,9 @@ When this skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
4. **If none are available**, tell the user and ask how to proceed.
@@ -42,7 +42,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Batch Generation Policy
@@ -73,7 +73,7 @@ Default behavior: **confirm before generation**.
Users may supply reference images via `--ref <files...>` or by providing file paths / pasting images in conversation. Refs guide style, palette, composition, or subject for specific illustrations.
Full detection, storage, and processing rules are in [references/workflow.md](references/workflow.md) (Step 1.0 saves to `references/NN-ref-{slug}.{ext}`; Step 5.3 processes per-illustration usage `direct | style | palette`). When the chosen backend supports batch input, `direct`-usage entries in each prompt file's `references:` frontmatter should be propagated into its batch payload so backends can pass them through (e.g. `baoyu-imagine` accepts `ref` per task).
Full detection, storage, and processing rules are in [references/workflow.md](references/workflow.md) (Step 1.0 saves to `references/NN-ref-{slug}.{ext}`; Step 5.3 processes per-illustration usage `direct | style | palette`). When the chosen backend supports batch input, `direct`-usage entries in each prompt file's `references:` frontmatter should be propagated into its batch payload so backends can pass them through (e.g. `baoyu-image-gen` accepts `ref` per task).
## Three Dimensions
@@ -261,7 +261,7 @@ EXTEND.md lives at the first matching path listed in Step 1.5. Three ways to cha
- **Common one-line edits**:
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
- `preferred_image_backend: ask` — confirm backend every run.
- `generation_batch_size: 4` — default number of images to render concurrently when the runtime supports parallel generation calls.
- `preferred_type: infographic`, `preferred_style: notion`, `preferred_palette: macaron`, `language: zh`.
@@ -56,7 +56,7 @@ custom_styles:
| `preferred_palette` | string | null | Palette override (macaron, warm, neon, or null) |
| `language` | string | null | Output language (null = auto-detect) |
| `default_output_dir` | enum | null | Output directory preference (null = ask each time) |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
| `custom_styles` | array | [] | User-defined styles |
@@ -381,7 +381,7 @@ Follow the `## Image Generation Tools` rule at the top of `SKILL.md`. Concretely
| Skill Supports `--ref` | Action |
|------------------------|--------|
| Yes (e.g., baoyu-imagine with Google) | Pass reference images via `--ref` |
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
| No | Convert to text description, append to prompt |
**Verification**: Before generating, confirm reference processing:
+6 -6
View File
@@ -1,7 +1,7 @@
---
name: baoyu-comic
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and batch-capable image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
version: 1.57.0
version: 1.57.1
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
@@ -32,9 +32,9 @@ When this skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
4. **If none are available**, tell the user and ask how to proceed.
@@ -46,7 +46,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Batch Generation Policy
@@ -245,7 +245,7 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
### Step 7: Image Generation
**Pick a backend once per session** using the `## Image Generation Tools` rule at the top. If the backend is a repo skill (e.g., `baoyu-imagine`), read its `SKILL.md` and use its documented interface rather than its scripts.
**Pick a backend once per session** using the `## Image Generation Tools` rule at the top. If the backend is a repo skill (e.g., `baoyu-image-gen`), read its `SKILL.md` and use its documented interface rather than its scripts.
**7.1 Character sheet** — generate it (to `characters/characters.png`, aspect `4:3`) when the comic is multi-page with recurring characters. Skip for simple presets (e.g., four-panel minimalist) or single-page comics. Compress to JPEG before use-as-`--ref` (`sips -s format jpeg -s formatOptions 80 …` on macOS, `pngquant --quality=65-80 …` elsewhere) to avoid payload failures. The prompt file at `characters/characters.md` must exist before invoking the backend.
@@ -342,7 +342,7 @@ EXTEND.md lives at `.baoyu-skills/baoyu-comic/EXTEND.md` (project) or `~/.baoyu-
- **Common one-line edits**:
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
- `preferred_image_backend: ask` — confirm backend every run.
- `generation_batch_size: 4` — default number of page images to render concurrently when the backend/runtime supports batch or parallel generation.
- `watermark.enabled: true`, `preferred_art`, `preferred_tone`, `preferred_layout`, `language` — shift the auto-selection defaults and cosmetic choices.
@@ -50,7 +50,7 @@ character_presets:
| `preferred_layout` | string | null | Layout preference or null |
| `preferred_aspect` | string | null | Aspect ratio (3:4, 4:3, 16:9) |
| `language` | string | null | Output language (null = auto-detect) |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `generation_batch_size` | int | 4 | Number of page images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
| `character_presets` | array | [] | Preset character roles for styles like ohmsha |
+1 -1
View File
@@ -448,7 +448,7 @@ Character sheet is recommended for multi-page comics with recurring characters,
| Exists | No `--ref` support | **B**: Embed character descriptions in every prompt |
| Skipped | — | **C**: Prompt file contains all descriptions inline |
**Strategy A: Using `--ref` parameter** (e.g., baoyu-imagine)
**Strategy A: Using `--ref` parameter** (e.g., baoyu-image-gen)
- Read the chosen image generation skill's `SKILL.md`
- Invoke that installed skill via its documented interface, not by calling its scripts directly
+6 -6
View File
@@ -1,7 +1,7 @@
---
name: baoyu-cover-image
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 11 color palettes and 7 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
version: 1.117.3
version: 1.117.4
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-cover-image
@@ -28,7 +28,7 @@ When this skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. **Path resolution**: `scripts/codex-imagegen.sh` lives at the plugin/repo root, NOT relative to your shell cwd. From this `SKILL.md`'s base directory, the wrapper is at `../../scripts/codex-imagegen.sh` — resolve to an absolute path before invoking. Command shape:
```bash
<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh \
@@ -43,7 +43,7 @@ When this skill needs to render an image, resolve the backend in this order:
`--timeout` defaults to 300000 (5 min) per codex exec attempt; raise it (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
All input paths to the wrapper are auto-resolved against the wrapper's `process.cwd()` if you pass relative ones, but agents should pass absolute paths to be robust against cwd drift. Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed.
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
4. **If none are available**, tell the user and ask how to proceed.
@@ -55,7 +55,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Confirmation Policy
@@ -229,7 +229,7 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r
- `style`/`palette` → extract traits, append to prompt
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio.
- **`codex-imagegen`**: invoke `<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh` (NOT a cwd-relative `./scripts/...` — resolve the absolute path from this skill's base directory: `../../scripts/codex-imagegen.sh`) with `--image <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache, `--timeout <ms>` to override the default 300000 / 5-min per-attempt limit on slow networks). All input paths to the wrapper are auto-resolved against its `process.cwd()` if relative, but passing absolutes is more robust. Read the stdout JSON; act on `status` and `error_kind`.
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-imagine` skill: per the rule in `## Image Generation Tools` above.
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-image-gen` skill: per the rule in `## Image Generation Tools` above.
6. On failure: auto-retry once
### Step 5: Completion Report
@@ -280,7 +280,7 @@ EXTEND.md lives at the path noted in **Step 0**. Three ways to change it:
- **Common one-line edits**:
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
- `preferred_image_backend: ask` — confirm backend every run.
- `watermark.enabled: true`, `preferred_type`, `preferred_palette`, `preferred_rendering`, `default_aspect`, `quick_mode: true`, `language` — shift the auto-selection defaults and confirmation flow.
@@ -62,7 +62,7 @@ custom_palettes:
| `default_aspect` | string | "2.35:1" | Default aspect ratio |
| `quick_mode` | bool | false | Skip confirmation step |
| `language` | string | null | Output language (null = auto-detect) |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `custom_palettes` | array | [] | User-defined palettes |
## Type Options
-480
View File
@@ -1,480 +0,0 @@
---
name: baoyu-image-cards
description: Generates infographic image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for social media engagement. Use when user mentions "小红书图片", "小红书种草", "小绿书", "微信图文", "微信贴图", "image cards", "图片卡片", or wants social media infographic series.
version: 1.57.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-cards
---
# Image Card Series Generator
Break down complex content into eye-catching image card series with multiple style options.
## User Input Tools
When this skill prompts the user, follow this tool-selection rule (priority order):
1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes.
## Image Generation Tools
When this skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
4. **If none are available**, tell the user and ask how to proceed.
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
## Batch Generation Policy
After every prompt file for the current generation group has been saved and verified, generate images in batches by default.
Priority order:
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
3. If neither native batch nor parallel tool calls are available, generate sequentially.
Rules:
- Honor the image-1 anchor chain: generate image 1 first, then batch images 2+ using image 1 as the reference.
- Never start a batch until every selected prompt file for that batch exists on disk.
- Retry failed items once without regenerating successful items.
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
## Confirmation Policy
Default behavior: **confirm before generation**.
- Treat explicit skill invocation, a file path, matched signals/presets, and `EXTEND.md` defaults as **recommendation inputs only**. None of them authorizes skipping confirmation.
- Do **not** start Step 3 until the user completes Step 2.
- Skip confirmation only when the current request explicitly says to do so, for example: `--yes`, "直接生成", "不用确认", "跳过确认", "按默认出图", or equivalent wording.
- If confirmation is skipped explicitly, state the assumed strategy / style / layout / palette / count / backend in the next user-facing update before generating.
## Language
Respond in the user's language across questions, progress, errors, and completion summary. Keep technical tokens (style names, file paths, code) in English.
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Visual style (see Styles below) |
| `--layout <name>` | Information layout (see Layouts below) |
| `--palette <name>` | Color override: macaron / warm / neon |
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
## Dimensions
Three independent knobs combine freely:
| Dimension | Controls | Options |
|-----------|----------|---------|
| **Style** | Visual aesthetics (lines, decorations, rendering) | 12 styles (see Styles below) |
| **Layout** | Information structure (density, arrangement) | 8 layouts (see Layouts below) |
| **Palette** (optional) | Color override, replaces the style's default colors | macaron / warm / neon (see Palettes below) |
Example: `--style notion --layout dense` makes an intellectual knowledge card; add `--palette macaron` to soften the colors without changing notion's rendering rules. A `--preset` is a shorthand for style + layout (+ optional palette).
**Palette behavior**: no `--palette` → style's built-in colors; `--palette <name>` → overrides colors only, rendering rules unchanged. Some styles declare a `default_palette` (e.g., sketch-notes defaults to macaron).
## Styles (12)
| Style | Description |
|-------|-------------|
| `cute` (Default) | Sweet, adorable, girly aesthetic |
| `fresh` | Clean, refreshing, natural |
| `warm` | Cozy, friendly, approachable |
| `bold` | High impact, attention-grabbing |
| `minimal` | Ultra-clean, sophisticated |
| `retro` | Vintage, nostalgic, trendy |
| `pop` | Vibrant, energetic, eye-catching |
| `notion` | Minimalist hand-drawn line art, intellectual |
| `chalkboard` | Colorful chalk on black board, educational |
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
| `screen-print` | Bold poster art, halftone textures, limited colors, symbolic storytelling |
| `sketch-notes` | Hand-drawn educational infographic, macaron pastels on warm cream, wobble lines |
Per-style specifications: `references/presets/<style>.md`.
## Layouts (8)
| Layout | Description |
|--------|-------------|
| `sparse` (Default) | 1-2 points, maximum impact |
| `balanced` | 3-4 points, standard |
| `dense` | 5-8 points, knowledge-card style |
| `list` | Enumeration / ranking (4-7 items) |
| `comparison` | Side-by-side contrast |
| `flow` | Process / timeline (3-6 steps) |
| `mindmap` | Center-radial (4-8 branches) |
| `quadrant` | Four-quadrant / circular sections |
Layout specs: `references/elements/canvas.md`.
## Palettes (optional override)
Replaces the style's colors while keeping rendering rules (line treatment, textures) intact.
| Palette | Background | Zone Colors | Accent | Feel |
|---------|------------|-------------|--------|------|
| `macaron` | Warm cream #F5F0E8 | Blue #A8D8EA, Lavender #D5C6E0, Mint #B5E5CF, Peach #F8D5C4 | Coral #E8655A | Soft, educational |
| `warm` | Soft peach #FFECD2 | Orange #ED8936, Terracotta #C05621, Golden #F6AD55, Rose #D4A09A | Sienna #A0522D | Earth tones, cozy |
| `neon` | Dark purple #1A1025 | Cyan #00F5FF, Magenta #FF00FF, Green #39FF14, Pink #FF6EC7 | Yellow #FFFF00 | High-energy, futuristic |
Palette specs: `references/palettes/<palette>.md`.
## Presets (style + layout shortcuts)
Quick-start combos, grouped by scenario. Use `--preset <name>` or recommend during Step 2.
**Knowledge & Learning**:
| Preset | Style | Layout | Best For |
|--------|-------|--------|----------|
| `knowledge-card` | notion | dense | 干货知识卡、概念科普 |
| `checklist` | notion | list | 清单、排行榜 |
| `concept-map` | notion | mindmap | 概念图、知识脉络 |
| `swot` | notion | quadrant | SWOT 分析、四象限 |
| `tutorial` | chalkboard | flow | 教程步骤、操作流程 |
| `classroom` | chalkboard | balanced | 课堂笔记、知识讲解 |
| `study-guide` | study-notes | dense | 学习笔记、考试重点 |
| `hand-drawn-edu` | sketch-notes | flow | 手绘教程、流程图解 |
| `sketch-card` | sketch-notes | dense | 手绘知识卡 |
| `sketch-summary` | sketch-notes | balanced | 手绘总结、图文笔记 |
**Lifestyle & Sharing**:
| Preset | Style | Layout | Best For |
|--------|-------|--------|----------|
| `cute-share` | cute | balanced | 少女风分享、日常种草 |
| `girly` | cute | sparse | 甜美封面、氛围感 |
| `cozy-story` | warm | balanced | 生活故事、情感分享 |
| `product-review` | fresh | comparison | 产品对比、测评 |
| `nature-flow` | fresh | flow | 健康流程、自然主题 |
**Impact & Opinion**:
| Preset | Style | Layout | Best For |
|--------|-------|--------|----------|
| `warning` | bold | list | 避坑指南、重要提醒 |
| `versus` | bold | comparison | 正反对比 |
| `clean-quote` | minimal | sparse | 金句、极简封面 |
| `pro-summary` | minimal | balanced | 专业总结、商务内容 |
**Trend & Entertainment**:
| Preset | Style | Layout | Best For |
|--------|-------|--------|----------|
| `retro-ranking` | retro | list | 复古排行、经典盘点 |
| `throwback` | retro | balanced | 怀旧分享 |
| `pop-facts` | pop | list | 趣味冷知识 |
| `hype` | pop | sparse | 炸裂封面、惊叹分享 |
**Poster & Editorial**:
| Preset | Style | Layout | Best For |
|--------|-------|--------|----------|
| `poster` | screen-print | sparse | 海报风封面、影评书评 |
| `editorial` | screen-print | balanced | 观点文章、文化评论 |
| `cinematic` | screen-print | comparison | 电影对比、戏剧张力 |
Full prompt-fragment definitions: `references/style-presets.md`.
## Auto-Selection
Match content signals to the best combo. First row whose keywords appear wins; fall back to `cute-share` if nothing matches.
| Signals in source | Style | Layout | Recommended preset |
|-------------------|-------|--------|--------------------|
| beauty, fashion, cute, girl, pink | `cute` | sparse/balanced | `cute-share`, `girly` |
| health, nature, fresh, organic | `fresh` | balanced/flow | `product-review`, `nature-flow` |
| life, story, emotion, warm | `warm` | balanced | `cozy-story` |
| warning, important, must, critical | `bold` | list/comparison | `warning`, `versus` |
| professional, business, elegant | `minimal` | sparse/balanced | `clean-quote`, `pro-summary` |
| classic, vintage, traditional | `retro` | balanced | `throwback`, `retro-ranking` |
| fun, exciting, wow, amazing | `pop` | sparse/list | `hype`, `pop-facts` |
| knowledge, concept, productivity, SaaS | `notion` | dense/list | `knowledge-card`, `checklist` |
| education, tutorial, learning, classroom | `chalkboard` | balanced/dense | `tutorial`, `classroom` |
| notes, handwritten, study guide, realistic | `study-notes` | dense/list/mindmap | `study-guide` |
| movie, poster, opinion, editorial, cinematic | `screen-print` | sparse/comparison | `poster`, `editorial`, `cinematic` |
| hand-drawn, infographic, workflow, 手绘, 图解 | `sketch-notes` | flow/balanced/dense | `hand-drawn-edu`, `sketch-card`, `sketch-summary` |
## Style × Layout Matrix
Compatibility scores (✓✓ highly recommended, ✓ works well, ✗ avoid). Use when the user picks a non-default combo and you want to flag a poor match.
| | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|--------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
| screen-print | ✓✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| sketch-notes | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
## Outline Strategies
Three differentiated approaches — each produces a structurally different outline. The workflow recommends one; Path C generates all three and lets the user choose.
| Strategy | Concept | Best for | Structure |
|----------|---------|----------|-----------|
| **A — Story-Driven** | Personal experience as the thread, emotional resonance first | Reviews, personal shares, transformation | Hook → Problem → Discovery → Experience → Conclusion |
| **B — Information-Dense** | Value-first, efficient information delivery | Tutorials, comparisons, checklists | Core conclusion → Info card → Pros/Cons → Recommendation |
| **C — Visual-First** | Visual impact as core, minimal text | High-aesthetic products, lifestyle, mood content | Hero image → Detail shots → Lifestyle scene → CTA |
## Reference Images
User-supplied refs are **separate from** the internal "image-1 as anchor" chain (Step 3) — they layer on top of it.
**Intake**: via `--ref <files...>` or paths pasted in conversation.
- File path → copy to `refs/NN-ref-{slug}.{ext}`
- Pasted with no path → ask for the path, or extract style traits as a text fallback
**Usage modes** (per reference):
| Usage | Effect |
|-------|--------|
| `direct` | Pass the file to the backend (typically on image 1 only, so the anchor propagates through the chain) |
| `style` | Extract style traits and append to every card's prompt body |
| `palette` | Extract hex colors and append to every card's prompt body |
Record refs in each affected card's prompt frontmatter:
```yaml
references:
- ref_id: 01
filename: 01-ref-brand.png
usage: direct
```
At generation time: verify files exist. Image 1 with `usage: direct` + backend that accepts refs → pass via the backend's ref parameter (becomes the chain anchor). Images 2+ keep using image-1 as `--ref` per Step 3 — do NOT re-stack user refs on top (avoids conflicting signals). For `style`/`palette`, embed extracted traits in every prompt.
## File Layout
```
image-cards/{topic-slug}/
├── source-{slug}.{ext}
├── analysis.md
├── outline-strategy-{a,b,c}.md # Path C only
├── outline.md
├── prompts/NN-{type}-{slug}.md
├── NN-{type}-{slug}.png
└── refs/ # only if --ref used
```
**Slug**: 2-4 words, kebab-case. "AI 工具推荐" → `ai-tools-recommend`. On collision, append `-YYYYMMDD-HHMMSS`.
**Backup rule** (applies throughout): before overwriting any file — source, outline, prompt, image — rename the existing one to `<name>-backup-YYYYMMDD-HHMMSS.<ext>`. This protects user edits.
## Workflow
```
- [ ] Step 0: Load EXTEND.md ⛔ BLOCKING (interactive only)
- [ ] Step 1: Analyze content → analysis.md
- [ ] Step 2: Smart Confirm ⚠️ REQUIRED (Path A / B / C)
- [ ] Step 3: Generate images
- [ ] Step 4: Completion report
```
### Step 0: Load EXTEND.md ⛔ BLOCKING
Check these paths in order; first hit wins:
| Path | Scope |
|------|-------|
| `.baoyu-skills/baoyu-image-cards/EXTEND.md` | Project |
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-cards/EXTEND.md` | XDG |
| `$HOME/.baoyu-skills/baoyu-image-cards/EXTEND.md` | User home |
- **Found** → read, parse, print a summary (style / layout / watermark / language), continue.
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference, preferred image backend, generation batch size. Schema: `references/config/preferences-schema.md`.
### Step 1: Analyze Content → `analysis.md`
1. Save the source (backup rule applies if `source.md` exists).
2. Run the deep analysis in `references/workflows/analysis-framework.md`: content type, hook potential, audience, engagement signals, visual opportunity map, swipe flow.
3. Detect source language, pick recommended image count (2-10).
4. Auto-recommend strategy + style + layout + palette using the **Auto-Selection** table above.
5. Write everything to `analysis.md`.
### Step 2: Smart Confirm ⚠️ REQUIRED
**Hard gate**: this step is mandatory per the [Confirmation Policy](#confirmation-policy) — Step 3 cannot start until the user confirms here (or explicitly opts out with `--yes` / equivalent wording in the current request).
Goal: present the auto-recommended plan and let the user confirm or adjust. Skip this step entirely under `--yes` — proceed with Path A using the analysis and any CLI overrides.
**Display summary** before asking:
```
📋 内容分析
主题:[topic] | 类型:[content_type]
要点:[key points]
受众:[audience]
🎨 推荐方案(自动匹配)
策略:[A/B/C] [name][reason]
风格:[style] · 布局:[layout] · 配色:[palette or 默认] · 预设:[preset]
图片:[N]张(封面+[N-2]内容+结尾)
元素:[background] / [decorations] / [emphasis]
```
Then ask one question — three paths. Verbatim option copy: `references/confirmation.md`.
**Path A — Quick confirm** (trust auto-recommendation): generate a single outline using the recommended strategy + style → save to `outline.md` → Step 3.
**Path B — Customize**: ask five questions (strategy/style, layout, palette, count, optional notes) with the recommendation pre-filled — blanks keep the recommendation. Generate one outline with the user's choices → `outline.md` → Step 3. See `references/confirmation.md`.
**Path C — Detailed mode**: two sub-confirmations.
- *Step 2a — Content understanding*: ask selling points (multi-select), audience, style preference (authentic / professional / aesthetic / auto), optional context. Update `analysis.md`.
- *Step 2b — Three outline variants*: generate `outline-strategy-a.md`, `outline-strategy-b.md`, `outline-strategy-c.md`. Each MUST have a different structure AND a different recommended style — include `style_reason` in the frontmatter. Page-count heuristic: A ~4-6, B ~3-5, C ~3-4. Template: `references/workflows/outline-template.md`; frontmatter example in `references/confirmation.md`.
- *Step 2c — Selection*: ask three questions (outline A/B/C/Combined, style, visual elements). Save selected/merged outline to `outline.md` → Step 3.
### Step 3: Generate Images
With confirmed outline + style + layout + palette:
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
Generation flow:
1. Write the full prompt for every image to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies), then verify all selected prompt files exist.
2. Generate **image 1** first without `--ref`; backup rule applies to the PNG file. This establishes the anchor.
3. Build a task list for **images 2+** using image 1 as `--ref <path-to-image-01.png>`.
4. Dispatch images 2+ in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback.
5. Report progress after each completed image. On failure, retry only the failed item once from the same saved prompt file.
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
```
Include a subtle watermark "[content]" positioned at [position].
The watermark should be legible but not distracting.
```
See `references/config/watermark-guide.md`.
**Backend selection**: per the Image Generation Tools rule at the top — use whatever is available, ask once if multiple, before any generation. Under `--yes`, use the EXTEND.md preference and fall back to the first available backend. Prompt files MUST exist before invoking any backend.
**Session ID** (if the backend supports `--sessionId`): use `cards-{topic-slug}-{timestamp}` for every image; combined with the ref chain this gives maximum consistency.
### Step 4: Completion Report
```
Image Card Series Complete!
Topic: [topic]
Mode: [Quick / Custom / Detailed]
Strategy: [A/B/C/Combined]
Style: [name]
Palette: [name or "default"]
Layout: [name or "varies"]
Location: [directory]
Images: N total
✓ analysis.md
✓ outline.md
✓ outline-strategy-a/b/c.md (detailed mode only)
- 01-cover-[slug].png ✓ Cover (sparse)
- 02-content-[slug].png ✓ Content (balanced)
- ...
- NN-ending-[slug].png ✓ Ending (sparse)
```
## Content Breakdown Principles
| Position | Purpose | Typical layout |
|----------|---------|----------------|
| Cover (image 1) | Hook + visual impact | `sparse` |
| Content (middle) | Core value per image | `balanced` / `dense` / `list` / `comparison` / `flow` |
| Ending (last) | CTA / summary | `sparse` or `balanced` |
For the style × layout compatibility matrix, see the **Style × Layout Matrix** above.
## Image Modification
| Action | How |
|--------|-----|
| Edit | Update `prompts/NN-{type}-{slug}.md` **first**, then regenerate with the same session ID |
| Add | Specify position, create prompt, generate, renumber subsequent files `NN+1`, update outline |
| Delete | Remove files, renumber subsequent `NN-1`, update outline |
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
Text correction policy:
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References
| File | Content |
|------|---------|
| `references/confirmation.md` | Verbatim AskUserQuestion copy for every confirmation path |
| `references/style-presets.md` | Full preset shortcut definitions |
| `references/presets/<style>.md` | Per-style element definitions |
| `references/palettes/<name>.md` | Per-palette color definitions |
| `references/elements/canvas.md` | Aspect ratios, safe zones, grid layouts |
| `references/elements/image-effects.md` | Cutout, stroke, filters |
| `references/elements/typography.md` | Decorated text, tags, text direction |
| `references/elements/decorations.md` | Emphasis marks, backgrounds, doodles, frames |
| `references/workflows/analysis-framework.md` | Content analysis framework |
| `references/workflows/outline-template.md` | Outline template with layout guide |
| `references/workflows/prompt-assembly.md` | Prompt assembly guide |
| `references/config/preferences-schema.md` | EXTEND.md schema |
| `references/config/first-time-setup.md` | First-time setup flow |
| `references/config/watermark-guide.md` | Watermark configuration |
## Notes
- Auto-retry once on generation failure before reporting an error.
- For sensitive public figures, use stylized cartoon alternatives.
- Smart Confirm (Step 2) is required; Detailed mode adds a second confirmation (2a + 2c).
## Changing Preferences
EXTEND.md lives at the first matching path listed in Step 0. Three ways to change it:
- **Edit directly** — open EXTEND.md and change fields. Full schema: `references/config/preferences-schema.md`.
- **Reconfigure interactively** — delete EXTEND.md (or ask "reconfigure baoyu-image-cards preferences" / "重新配置"). The next run re-triggers first-time setup.
- **Common one-line edits**:
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
- `preferred_image_backend: ask` — confirm backend every run.
- `generation_batch_size: 4` — default number of images to render concurrently when the backend/runtime supports batch or parallel generation.
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
@@ -1,125 +0,0 @@
---
name: first-time-setup
description: First-time setup flow for baoyu-xhs-images preferences
---
# First-Time Setup
## Overview
When no EXTEND.md is found, guide user through preference setup.
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
- Ask about content/article
- Ask about style or layout
- Ask about target audience
- Proceed to content analysis
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
## Setup Flow
```
No EXTEND.md found
┌─────────────────────┐
│ AskUserQuestion │
│ (all questions) │
└─────────────────────┘
┌─────────────────────┐
│ Create EXTEND.md │
└─────────────────────┘
Continue to Step 1
```
## Questions
**Language**: Use user's input language or saved language preference.
Use single AskUserQuestion with multiple questions (AskUserQuestion auto-adds "Other" option):
### Question 1: Watermark
```
header: "Watermark"
question: "Watermark text for generated images? Type your watermark content (e.g., name, @handle)"
options:
- label: "No watermark (Recommended)"
description: "No watermark, can enable later in EXTEND.md"
```
Position defaults to bottom-right.
### Question 2: Preferred Style
```
header: "Style"
question: "Default visual style preference? Or type another style name or your custom style"
options:
- label: "None (Recommended)"
description: "Auto-select based on content analysis"
- label: "cute"
description: "Sweet, adorable - classic XHS aesthetic"
- label: "notion"
description: "Minimalist hand-drawn, intellectual"
```
### Question 3: Save Location
```
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"
```
## Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| Project | `.baoyu-skills/baoyu-xhs-images/EXTEND.md` | Current project |
| User | `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` | All projects |
## After Setup
1. Create directory if needed
2. Write EXTEND.md with frontmatter
3. Confirm: "Preferences saved to [path]"
4. Continue to Step 1
## EXTEND.md Template
```yaml
---
version: 1
watermark:
enabled: [true/false]
content: "[user input or empty]"
position: bottom-right
opacity: 0.7
preferred_style:
name: [selected style or null]
description: ""
preferred_layout: null
language: null
preferred_image_backend: auto
generation_batch_size: 4
custom_styles: []
---
```
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
`generation_batch_size: 4` is the baked-in default for batch rendering. The current user request may override it for one run.
## Modifying Preferences Later
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
@@ -1,128 +0,0 @@
---
name: preferences-schema
description: EXTEND.md YAML schema for baoyu-xhs-images user preferences
---
# Preferences Schema
## Full Schema
```yaml
---
version: 1
watermark:
enabled: false
content: ""
position: bottom-right # bottom-right|bottom-left|bottom-center|top-right
preferred_style:
name: null # Built-in or custom style name
description: "" # Override/notes
preferred_layout: null # sparse|balanced|dense|list|comparison|flow
language: null # zh|en|ja|ko|auto
preferred_image_backend: auto # auto|ask|<backend-id>
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel generation
custom_styles:
- name: my-style
description: "Style description"
color_palette:
primary: ["#FED7E2", "#FEEBC8"]
background: "#FFFAF0"
accents: ["#FF69B4", "#FF6B6B"]
visual_elements: "Hearts, stars, sparkles"
typography: "Rounded, bubbly hand lettering"
best_for: "Lifestyle, beauty"
---
```
## Field Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | 1 | Schema version |
| `watermark.enabled` | bool | false | Enable watermark |
| `watermark.content` | string | "" | Watermark text (@username or custom) |
| `watermark.position` | enum | bottom-right | Position on image |
| `preferred_style.name` | string | null | Style name or null |
| `preferred_style.description` | string | "" | Custom notes/override |
| `preferred_layout` | string | null | Layout preference or null |
| `language` | string | null | Output language (null = auto-detect) |
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
| `custom_styles` | array | [] | User-defined styles |
## Position Options
| Value | Description |
|-------|-------------|
| `bottom-right` | Lower right corner (default, most common) |
| `bottom-left` | Lower left corner |
| `bottom-center` | Bottom center |
| `top-right` | Upper right corner |
## Custom Style Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Unique style identifier (kebab-case) |
| `description` | Yes | What the style conveys |
| `color_palette.primary` | No | Main colors (array) |
| `color_palette.background` | No | Background color |
| `color_palette.accents` | No | Accent colors (array) |
| `visual_elements` | No | Decorative elements |
| `typography` | No | Font/lettering style |
| `best_for` | No | Recommended content types |
## Example: Minimal Preferences
```yaml
---
version: 1
watermark:
enabled: true
content: "@myusername"
preferred_style:
name: notion
---
```
## Example: Full Preferences
```yaml
---
version: 1
watermark:
enabled: true
content: "@myxhsaccount"
position: bottom-right
preferred_style:
name: notion
description: "Clean knowledge cards for tech content"
preferred_layout: dense
language: zh
preferred_image_backend: codex-imagegen
generation_batch_size: 4
custom_styles:
- name: corporate
description: "Professional B2B style"
color_palette:
primary: ["#1E3A5F", "#4A90D9"]
background: "#F5F7FA"
accents: ["#00B4D8", "#48CAE4"]
visual_elements: "Clean lines, subtle gradients, geometric shapes"
typography: "Modern sans-serif, professional"
best_for: "Business, SaaS, enterprise"
---
```
@@ -1,62 +0,0 @@
---
name: watermark-guide
description: Watermark configuration guide for baoyu-xhs-images
---
# Watermark Guide
## Position Diagram
```
┌─────────────────────────────┐
│ [top-right]│
│ │
│ │
│ IMAGE CONTENT │
│ │
│ │
│[bottom-left][bottom-center][bottom-right]│
└─────────────────────────────┘
```
## Position Recommendations
| Position | Best For | Avoid When |
|----------|----------|------------|
| `bottom-right` | Default choice, most common | Key info in bottom-right |
| `bottom-left` | Right-heavy layouts | Key info in bottom-left |
| `bottom-center` | Centered designs | Text-heavy bottom area |
| `top-right` | Bottom-heavy content | Title/header in top-right |
## Content Format
| Format | Example | Style |
|--------|---------|-------|
| Handle | `@username` | Most common for XHS |
| Text | `MyBrand` | Simple branding |
| Chinese | `小红书:用户名` | Platform specific |
| URL | `myblog.com` | Cross-platform |
## Best Practices
1. **Consistency**: Use same watermark across all images in series
2. **Legibility**: Ensure watermark readable on both light/dark areas
3. **Size**: Keep subtle - should not distract from content
## Prompt Integration
When watermark is enabled, add to image generation prompt:
```
Include a subtle watermark "[content]" positioned at [position].
The watermark should be legible but not distracting from the main content.
```
## Common Issues
| Issue | Solution |
|-------|----------|
| Watermark invisible | Adjust position or check contrast |
| Watermark too prominent | Change position or reduce size |
| Watermark overlaps content | Change position |
| Inconsistent across images | Use session ID for consistency |
@@ -1,156 +0,0 @@
# Confirmation Questions
Concrete option copy for Step 2 Smart Confirm. SKILL.md states which question to ask and when — this file supplies the verbatim options used in Claude Code. Other runtimes should adapt the wording to their native user-input tool while preserving intent.
## Step 2 — Smart Confirm Entry
Single-question confirmation presented right after the auto-recommended plan.
```yaml
header: Mode
question: How to proceed with the recommended plan?
options:
- label: 1. ✅ 确认,直接生成(推荐)
description: Trust auto-recommendation and proceed immediately
- label: 2. 🎛️ 自定义调整
description: Modify strategy/style/layout/count in one step
- label: 3. 📋 详细模式
description: Generate 3 outline variants, then choose (two confirmations)
```
## Path B — Customize (Option 2)
Batch these five questions. Leaving a field blank keeps the recommended value.
```yaml
header: Style/Strategy
question: "Strategy + style. Current: {strategy} + {style}"
hint: |
Strategies: A Story-Driven (warm) | B Information-Dense (notion) | C Visual-First (screen-print)
Styles: cute / fresh / warm / bold / minimal / retro / pop / notion / chalkboard / study-notes / screen-print / sketch-notes
Presets: knowledge-card / checklist / tutorial / poster / hand-drawn-edu / ...
```
```yaml
header: Layout
question: "Layout. Current: {layout}"
options: [sparse, balanced, dense, list, comparison, flow, mindmap, quadrant]
```
```yaml
header: Palette
question: "Palette. Current: {palette or 默认}"
options: [默认, macaron, warm, neon]
```
```yaml
header: Count
question: "Image count. Current: {N}"
hint: Range 2-10
```
```yaml
header: Notes
question: Optional notes (selling-point emphasis, audience adjustment, color preference)
optional: true
```
## Path C — Detailed Mode
### Step 2a: Content Understanding
Batch these questions.
```yaml
header: SellingPoints
question: Core selling points (pick all that apply)
multiSelect: true
```
```yaml
header: Audience
question: Target audience
```
```yaml
header: Tone
question: Style preference
options:
- label: Authentic sharing
- label: Professional review
- label: Aesthetic mood
- label: Auto
```
```yaml
header: Context
question: Additional context (optional)
optional: true
```
### Step 2c: Outline & Style Selection
Batch these three questions.
```yaml
header: Strategy
question: Which outline strategy?
options:
- label: A — Story-Driven
- label: B — Information-Dense
- label: C — Visual-First
- label: Combine (specify pages from each)
```
```yaml
header: Style
question: Visual style?
options:
- label: Use recommended
- label: Select preset
- label: Select style directly
- label: Custom description
```
```yaml
header: Elements
question: Visual elements?
options:
- label: Use defaults (Recommended)
- label: Adjust background
- label: Adjust decorations
- label: Custom
```
## Outline Variant Frontmatter
Used by Path C when writing the three `outline-strategy-{a,b,c}.md` files. Each variant MUST have a different structure AND a different recommended style — include `style_reason` explaining why the style fits the strategy.
```yaml
---
strategy: a # a | b | c
name: Story-Driven
style: warm # recommended style for this strategy
palette: ~ # optional: macaron | warm | neon | ~ (style default)
style_reason: "Warm tones enhance emotional storytelling and personal connection"
elements:
background: solid-pastel
decorations: [clouds, stars-sparkles]
emphasis: star-burst
typography: highlight
layout: balanced
image_count: 5
---
## P1 Cover
**Type**: cover
**Hook**: "入冬后脸不干了🥹终于找到对的面霜"
**Visual**: Product hero shot with cozy winter atmosphere
**Layout**: sparse
## P2 Problem
**Type**: pain-point
...
```
Page-count heuristic: strategy A typically 4-6 pages, B typically 3-5, C typically 3-4.
@@ -1,122 +0,0 @@
# Canvas & Layout
Core canvas specifications and layout grids for Xiaohongshu infographics.
## Aspect Ratios
| Name | Ratio | Pixels | Note |
|------|-------|--------|------|
| portrait-3-4 | 3:4 | 1242×1660 | Highest traffic on XHS (recommended) |
| square | 1:1 | 1242×1242 | Second recommended |
| portrait-2-3 | 2:3 | 1242×1863 | Taller format |
**Default**: portrait-3-4 for maximum engagement.
## Safe Zones
Avoid placing critical content in these areas:
| Zone | Position | Reason |
|------|----------|--------|
| bottom-overlay | Bottom 10% | Title bar overlay on mobile |
| top-right | Top-right corner | Like/share button overlay |
| bottom-right | Bottom-right corner | Watermark position |
```
┌─────────────────────────────┐
│ [like/share]│ ← top-right: avoid
│ │
│ │
│ ✓ SAFE CONTENT AREA │
│ │
│ │
│ [title bar overlay area] │ ← bottom 10%: avoid key info
└─────────────────────────────┘
```
## Grid Layouts
### Density-Based Layouts
| Layout | Info Density | Whitespace | Points/Image | Best For |
|--------|--------------|------------|--------------|----------|
| sparse | Low | 60-70% | 1-2 | Covers, quotes, impactful statements |
| balanced | Medium | 40-50% | 3-4 | Standard content, tutorials |
| dense | High | 20-30% | 5-8 | Knowledge cards, cheat sheets |
### Structure-Based Layouts
| Layout | Structure | Items | Best For |
|--------|-----------|-------|----------|
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
| mindmap | Center radial | 4-8 branches | Concept maps, brainstorming, topic overview |
| quadrant | 4-section grid | 4 sections | SWOT analysis, priority matrix, classification |
## Layout by Position
| Position | Recommended Layout | Why |
|----------|-------------------|-----|
| Cover | sparse | Maximum visual impact, clear title |
| Setup | balanced | Context without overwhelming |
| Core | balanced/dense/list | Based on content density |
| Payoff | balanced/list | Clear takeaways |
| Ending | sparse | Clean CTA, memorable close |
## Grid Cells
For multi-element compositions:
| Name | Cells | Use Case |
|------|-------|----------|
| single | 1 | Hero image, maximum impact |
| dual | 2 | Before/after, comparison |
| triptych | 3 | Steps, process flow |
| quad | 4 | Product showcase |
| six-grid | 6 | Checklist, collection |
| nine-grid | 9 | Multi-image gallery |
## Visual Balance
### Sparse Layout
- Single focal point centered
- Breathing room on all sides
- Symmetrical composition
### Balanced Layout
- Top-weighted title
- Evenly distributed content below
- Clear visual hierarchy
### Dense Layout
- Organized grid structure
- Clear section boundaries
- Compact but readable spacing
### List Layout
- Left-aligned items
- Clear number/bullet hierarchy
- Consistent item format
### Comparison Layout
- Symmetrical left/right
- Clear visual contrast
- Divider between sections
### Flow Layout
- Directional flow (top→bottom or left→right)
- Connected nodes with arrows
- Clear progression indicators
### Mindmap Layout
- Central topic node
- Radial branches outward
- Hierarchical sub-branches
- Organic curved connections
### Quadrant Layout
- 4-section grid (2×2)
- Clear axis labels
- Each quadrant with distinct content
- Optional circular variant for cycles
@@ -1,152 +0,0 @@
# Decorative Assets
Visual embellishments and decorative elements for Xiaohongshu infographics.
## Emphasis Marks (强调标记)
Elements to draw attention to specific content.
| Name | Description | Use Case |
|------|-------------|----------|
| red-arrow | Red arrow pointing to target | Product features, key points |
| circle-mark | Circle highlight annotation | Highlighting details |
| underline | Straight or wavy underline | Text emphasis |
| star-burst | Starburst explosion effect | Special offers, wow factor |
| checkmark | Checkmark/tick symbol | Completed items, pros |
| cross-mark | X mark symbol | Cons, things to avoid |
| exclamation | Exclamation point decoration | Important warnings |
| question | Question mark decoration | FAQ, curiosity |
| numbering | Circled numbers | Steps, rankings |
| bracket | Bracket highlighting | Grouping, emphasis |
## Backgrounds (背景)
Base layer treatments.
| Name | Description | Use Case |
|------|-------------|----------|
| solid-saturated | High-saturation solid color | Bold, energetic |
| solid-pastel | Soft pastel solid color | Cute, gentle |
| gradient-linear | Linear color gradient | Modern, dynamic |
| gradient-radial | Radial color gradient | Spotlight effect |
| frosted-glass | Frosted glass blur effect | Layered compositions |
| paper-texture | Paper or craft texture | Handmade aesthetic |
| fabric-texture | Fabric/cloth texture | Cozy, tactile |
| chalkboard | Blackboard texture | Educational content |
| grid | Subtle grid pattern | Structured, organized |
| dots | Polka dot pattern | Playful, retro |
## Doodles & Emoji (涂鸦)
Hand-drawn decorative elements.
| Name | Description | Use Case |
|------|-------------|----------|
| hand-drawn-lines | Sketchy hand-drawn lines | Connections, borders |
| stars-sparkles | Stars and sparkle effects | Magic, excellence |
| flowers | Floral decorations | Beauty, feminine |
| hearts | Heart symbols | Love, favorites |
| clouds | Cloud shapes | Dreamy, thoughts |
| arrows-curvy | Curved directional arrows | Flow, direction |
| squiggles | Wavy squiggle lines | Energy, movement |
| confetti | Scattered confetti | Celebration |
| leaves | Leaf decorations | Nature, fresh |
| bubbles | Circular bubble shapes | Playful, light |
## Emoji Integration
| Category | Examples | Use Case |
|----------|----------|----------|
| Reactions | 🥹 😍 🤯 | Emotional emphasis |
| Objects | ✨ 💡 🎯 | Visual markers |
| Actions | 👇 👆 ➡️ | Directional cues |
| Nature | 🌸 🌿 ☀️ | Thematic decoration |
## Frames (边框)
Container and border treatments.
| Name | Description | Use Case |
|------|-------------|----------|
| polaroid | Instant photo frame | Photo showcase |
| film-strip | Film negative border | Cinematic, retro |
| phone-screenshot | Mobile device mockup | App/screen content |
| torn-paper | Torn paper edge effect | Scrapbook aesthetic |
| rounded-rect | Rounded rectangle border | Clean containers |
| decorative | Ornate decorative border | Premium, elegant |
| tape-corners | Washi tape corners | Crafty, casual |
| stamp-border | Stamp perforated edge | Vintage, postal |
## Dividers (分隔线)
Section separators.
| Name | Description | Use Case |
|------|-------------|----------|
| line-simple | Simple horizontal line | Clean separation |
| line-dashed | Dashed line | Subtle division |
| line-wavy | Wavy line | Playful separation |
| dots-row | Row of dots | Decorative division |
| ornamental | Decorative flourish | Elegant separation |
## Stickers (贴纸)
Pre-composed decorative elements.
| Name | Description | Use Case |
|------|-------------|----------|
| badge-new | "NEW" badge | New products |
| badge-hot | "HOT" badge | Trending items |
| badge-sale | Sale/discount badge | Promotions |
| seal-quality | Quality seal | Recommendations |
| ribbon-award | Award ribbon | Best picks |
| tag-price | Price tag shape | Pricing info |
## Style-Specific Decorations
### Cute Style
- Hearts, stars, sparkles
- Ribbon decorations, sticker-style
- Cute character elements
### Notion Style
- Simple line doodles
- Geometric shapes, stick figures
- Maximum whitespace, minimal decoration
### Warm Style
- Sun rays, coffee cups, cozy items
- Warm lighting effects
- Friendly, inviting decorations
### Fresh Style
- Plant leaves, clouds, water drops
- Simple geometric shapes
- Open, breathing composition
### Bold Style
- Exclamation marks, arrows
- Warning icons, strong shapes
- High contrast elements
### Pop Style
- Bold shapes, speech bubbles
- Comic-style effects, starburst
- Dynamic, energetic decorations
### Retro Style
- Halftone dots, vintage badges
- Classic icons, tape effects
- Aged texture overlays
### Chalkboard Style
- Chalk dust effects
- Hand-drawn doodles
- Mathematical formulas, simple icons
### Screen-Print Style
- Bold silhouettes, geometric shapes
- Halftone dot patterns, print grain
- No doodles — negative space does the work
- Stencil-cut edges, color block boundaries
- Vintage poster border treatments
@@ -1,92 +0,0 @@
# Image Processing Layer
Visual effects applied to image elements in Xiaohongshu infographics.
## AI Cutout (抠图)
Subject extraction styles for product/figure isolation.
| Name | Description | Use Case |
|------|-------------|----------|
| clean | Sharp edges, precise boundaries | Product photography, tech items |
| soft | Soft transition, feathered edges | Portrait cutout, organic subjects |
| stylized | Hand-drawn edge treatment | Artistic compositions |
## Stroke Effects (描边)
Border treatments for cutout elements.
| Name | Description | Use Case |
|------|-------------|----------|
| white-solid | White solid line border | Classic sticker feel, high contrast |
| colored-solid | Colored solid line border | Playful vibe, brand colors |
| dashed | Dashed/dotted border | Handmade aesthetic, casual |
| double | Double-layer stroke | Emphasis effect, premium feel |
| glow | Soft outer glow | Dreamy, soft aesthetic |
| shadow | Drop shadow effect | Depth, floating element |
**Stroke Width Guidelines**:
- Thin: 2-4px - Subtle, elegant
- Medium: 5-8px - Standard visibility
- Thick: 10-15px - Bold emphasis
## Filters (滤镜)
Color grading and mood presets popular on XHS.
| Name | Chinese | Description | Mood |
|------|---------|-------------|------|
| clear-glow | 清透感 | Transparent, radiant, luminous | Fresh, youthful |
| film-grain | 胶片感 | Vintage film aesthetic, grain texture | Nostalgic, artistic |
| cream-skin | 奶油肌 | Smooth, creamy complexion tones | Soft, flattering |
| japanese-magazine | 日杂感 | Lifestyle magazine aesthetic | Curated, aspirational |
| high-saturation | 高饱和 | Vibrant, punchy colors | Energetic, eye-catching |
| muted-tones | 莫兰迪 | Morandi-style desaturated palette | Sophisticated, calm |
| warm-tone | 暖色调 | Golden hour warmth | Cozy, inviting |
| cool-tone | 冷色调 | Blue-shifted coolness | Modern, clean |
## Texture Overlays
Additional texture effects.
| Name | Description | Use Case |
|------|-------------|----------|
| paper | Paper or fabric texture | Handmade feel |
| noise | Fine grain noise | Analog aesthetic |
| halftone | Dot pattern | Retro print style |
| scratch | Light scratch marks | Vintage wear |
## Blending Modes
For layered compositions.
| Mode | Effect | Use Case |
|------|--------|----------|
| multiply | Darken, merge | Shadow effects |
| screen | Lighten, glow | Light effects |
| overlay | Contrast boost | Vibrant compositions |
| soft-light | Subtle blending | Natural layering |
## Effect Combinations
Common effect stacks for different styles:
### Cute Style
- Filter: clear-glow or cream-skin
- Stroke: white-solid (medium)
- Texture: none
### Notion Style
- Filter: none or muted-tones
- Stroke: white-solid (thin) or none
- Texture: paper (subtle)
### Retro Style
- Filter: film-grain
- Stroke: double or dashed
- Texture: halftone, scratch
### Bold Style
- Filter: high-saturation
- Stroke: colored-solid (thick)
- Texture: none
@@ -1,96 +0,0 @@
# Typography System
Text styling elements for Xiaohongshu infographics.
## Decorated Text (花字)
Stylized text treatments for emphasis and visual appeal.
| Name | Description | Use Case |
|------|-------------|----------|
| gradient | Gradient color fill | Title emphasis, modern feel |
| stroke-text | Outlined text with stroke | Cover headlines, high visibility |
| shadow-3d | 3D shadow/extrusion effect | Key terms, depth |
| highlight | Highlighter marker effect | Critical information, key points |
| neon | Neon glow effect | Tech content, night aesthetic |
| handwritten | Authentic handwritten style | Personal touch, casual |
| bubble | Rounded, inflated letterforms | Cute, playful content |
| brush | Brush stroke texture | Artistic, dynamic |
## Tags & Labels (标签)
Structured text containers.
| Name | Description | Use Case |
|------|-------------|----------|
| black-white | Black background, white text | Brand names, prices, categories |
| white-black | White background, black text | Clean labels, minimal style |
| bubble | Speech bubble style | Dialogue, annotations, callouts |
| pointer | Arrow pointer with label | Product callouts, pointing to features |
| ribbon | Ribbon/banner shape | Special offers, highlights |
| stamp | Stamp/seal style | Authenticity, recommendations |
| pill | Rounded pill shape | Tags, categories, keywords |
## Text Hierarchy
Recommended text sizing for visual hierarchy.
| Level | Role | Relative Size | Style |
|-------|------|---------------|-------|
| H1 | Main title | 100% | Bold, decorated |
| H2 | Section header | 70-80% | Semi-bold |
| H3 | Subsection | 50-60% | Medium weight |
| Body | Content text | 40-50% | Regular |
| Caption | Small notes | 30-35% | Light |
## Text Direction
| Direction | Description | Use Case |
|-----------|-------------|----------|
| horizontal | Standard left-to-right | Default for most content |
| vertical | Top-to-bottom columns | Magazine style, traditional Chinese |
| curved | Text following a curve | Decorative, around shapes |
| diagonal | Angled text | Dynamic compositions |
## Text Effects
| Effect | Description | Use Case |
|--------|-------------|----------|
| shadow | Drop shadow behind text | Readability on busy backgrounds |
| outline | Outline around letterforms | High contrast visibility |
| glow | Soft glow around text | Dreamy, emphasis |
| underline-wavy | Wavy underline decoration | Playful emphasis |
| strikethrough | Crossed out text | Before/after, corrections |
## Language Considerations
### Chinese Text (中文)
- Punctuation: 「」()、。!?
- Spacing: No spaces between characters
- Line height: 1.5-1.8x for readability
### Mixed Text
- English in Chinese context: Maintain consistent baseline
- Numbers: Use consistent number style (lining vs old-style)
## Style-Specific Typography
### Cute Style
- Rounded, bubbly hand lettering
- Soft shadows, playful decorations
- Pink/pastel color accents
### Notion Style
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration
### Bold Style
- Impactful hand lettering with shadows
- High contrast colors
- Strong outlines
### Chalkboard Style
- Chalk texture on all text
- Visible imperfections
- Multi-color chalk variety
@@ -1,33 +0,0 @@
# Macaron Palette
Soft pastel color blocks on warm cream background. Gentle, approachable, educational feel.
## Background
- Color: Warm cream (#F5F0E8)
- Texture: Subtle paper grain, warm tone
## Colors
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Warm Cream | #F5F0E8 | Primary background |
| Text | Deep Charcoal | #2C3E50 | Titles, main content |
| Secondary Text | Warm Gray | #6B6B6B | Annotations, labels |
| Block Color | Macaron Blue | #A8D8EA | Content block fill |
| Block Color | Macaron Lavender | #D5C6E0 | Content block fill |
| Block Color | Macaron Mint | #B5E5CF | Content block fill |
| Block Color | Macaron Peach | #F8D5C4 | Content block fill |
| Accent | Coral Red | #E8655A | Emphasis, highlights |
## Semantic Constraint
Soft pastel macaron color palette. Use block colors as rounded card backgrounds for distinct information sections. Accent coral red sparingly for emphasis on key terms only. All colors should feel gentle and approachable — no saturated or neon tones. Do NOT render color names or role labels as visible text in the image.
## Best Paired With
- `sketch-notes` — natural pairing for hand-drawn educational content
- `notion` — macaron accents soften the monochrome aesthetic
- `chalkboard` — pastel chalk tones replace standard chalk colors
- `warm` — reinforces the cozy, friendly feel
- `fresh` — complements the clean, natural aesthetic
@@ -1,32 +0,0 @@
# Neon Palette
Vibrant neon colors on dark background. High-energy, futuristic, eye-catching.
## Background
- Color: Dark Purple (#1A1025)
- Texture: Smooth, deep
## Colors
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Dark Purple | #1A1025 | Primary background |
| Text | Bright White | #F0F0F0 | Titles, main content |
| Secondary Text | Light Lavender | #B8B8D4 | Annotations, labels |
| Block Color | Neon Cyan | #00F5FF | Content block fill |
| Block Color | Neon Magenta | #FF00FF | Content block fill |
| Block Color | Neon Green | #39FF14 | Content block fill |
| Block Color | Neon Pink | #FF6EC7 | Content block fill |
| Accent | Electric Yellow | #FFFF00 | Emphasis, highlights |
## Semantic Constraint
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe. Do NOT render color names, hex codes, or role labels as visible text in the image.
## Best Paired With
- `bold` — amplifies high-impact energy
- `pop` — neon takes the vibrancy further
- `minimal` — neon accents on dark create striking contrast
- `notion` — futuristic knowledge card aesthetic
@@ -1,32 +0,0 @@
# Warm Palette
Warm earth tones on soft peach background. Cozy, inviting, no cool colors.
## Background
- Color: Soft Peach (#FFECD2)
- Texture: Warm, slightly textured
## Colors
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Soft Peach | #FFECD2 | Primary background |
| Text | Deep Brown | #744210 | Titles, main content |
| Secondary Text | Warm Brown | #9C6644 | Annotations, labels |
| Block Color | Warm Orange | #ED8936 | Content block fill |
| Block Color | Terracotta | #C05621 | Content block fill |
| Block Color | Golden Yellow | #F6AD55 | Content block fill |
| Block Color | Dusty Rose | #D4A09A | Content block fill |
| Accent | Burnt Sienna | #A0522D | Emphasis, highlights |
## Semantic Constraint
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight. Do NOT render color names, hex codes, or role labels as visible text in the image.
## Best Paired With
- `warm` — natural pairing, amplifies cozy feel
- `cute` — warm pastels enhance the sweet aesthetic
- `retro` — earth tones complement vintage style
- `sketch-notes` — warm educational feel
@@ -1,72 +0,0 @@
---
name: bold
category: impact
---
# Bold Style
High impact, attention-grabbing aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: clean
stroke: colored-solid | double
filter: high-saturation
typography:
decorated: shadow-3d | stroke-text
tags: black-white | ribbon
direction: horizontal | diagonal
decorations:
emphasis: exclamation | star-burst | red-arrow
background: solid-saturated | gradient-linear
doodles: arrows-curvy | squiggles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Vibrant red, orange, yellow | #E53E3E, #DD6B20, #F6E05E |
| Background | Deep black, dark charcoal | #000000, #1A1A1A |
| Accents | White, neon yellow | #FFFFFF, #F7FF00 |
## Visual Elements
- Exclamation marks, arrows, warning icons
- Strong shapes, high contrast elements
- Dramatic compositions
- Bold geometric forms
## Typography
- Bold, impactful hand lettering with shadows
- High contrast text treatments
- Large, commanding headlines
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Impactful statements |
| balanced | ✓ | Warning content |
| dense | ✓ | Critical information cards |
| list | ✓✓ | Must-know lists, rankings |
| comparison | ✓✓ | Dramatic contrasts |
| flow | ✓ | Critical process steps |
## Best For
- Important tips and warnings
- Must-know content
- Critical announcements
- Rankings and comparisons
- Attention-grabbing hooks
@@ -1,97 +0,0 @@
---
name: chalkboard
category: educational
---
# Chalkboard Style
Black chalkboard background with colorful chalk drawing aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual | triptych
image_effects:
cutout: stylized
stroke: none
filter: none
typography:
decorated: handwritten
tags: none
direction: horizontal | vertical
decorations:
emphasis: underline | circle-mark | arrows-curvy
background: chalkboard
doodles: hand-drawn-lines | stars-sparkles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Background | Chalkboard black, green-black | #1A1A1A, #1C2B1C |
| Primary Text | Chalk white | #F5F5F5 |
| Accent 1 | Chalk yellow | #FFE566 |
| Accent 2 | Chalk pink | #FF9999 |
| Accent 3 | Chalk blue | #66B3FF |
| Accent 4 | Chalk green | #90EE90 |
| Accent 5 | Chalk orange | #FFB366 |
## Visual Elements
- Hand-drawn chalk illustrations with sketchy, imperfect lines
- Chalk dust effects around text and key elements
- Doodles: stars, arrows, underlines, circles, checkmarks
- Mathematical formulas and simple diagrams
- Eraser smudges and chalk residue textures
- Stick figures and simple icons
- Connection lines with hand-drawn feel
## Typography
- Hand-drawn chalk lettering style
- Visible chalk texture on all text
- Imperfect baseline adds authenticity
- White or bright colored chalk for emphasis
## Style Rules
### Do
- Maintain authentic chalk texture on all elements
- Use imperfect, hand-drawn quality throughout
- Add subtle chalk dust and smudge effects
- Create visual hierarchy with color variety
- Include playful doodles and annotations
### Don't
- Use perfect geometric shapes
- Create clean digital-looking lines
- Add photorealistic elements
- Use gradients or glossy effects
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Educational covers |
| balanced | ✓✓ | Standard lessons |
| dense | ✓✓ | Detailed tutorials |
| list | ✓✓ | Learning checklists |
| comparison | ✓ | Concept comparisons |
| flow | ✓✓ | Process explanations |
## Best For
- Educational content
- Tutorials and how-to's
- Classroom themes
- Teaching materials
- Workshops
- Informal learning sessions
- Knowledge sharing
@@ -1,72 +0,0 @@
---
name: cute
category: sweet
---
# Cute Style
Sweet, adorable, girly - classic Xiaohongshu aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual | quad
image_effects:
cutout: soft
stroke: white-solid | colored-solid
filter: clear-glow | cream-skin
typography:
decorated: bubble | highlight
tags: pill | bubble
direction: horizontal
decorations:
emphasis: star-burst | hearts
background: solid-pastel | gradient-linear
doodles: hearts | stars-sparkles | flowers
frames: polaroid | tape-corners
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Pink, peach, mint, lavender | #FED7E2, #FEEBC8, #C6F6D5, #E9D8FD |
| Background | Cream, soft pink | #FFFAF0, #FFF5F7 |
| Accents | Hot pink, coral | #FF69B4, #FF6B6B |
## Visual Elements
- Hearts, stars, sparkles, cute faces
- Ribbon decorations, sticker-style
- Cute stickers, emoji icons
- Soft, rounded shapes
## Typography
- Rounded, bubbly hand lettering
- Soft shadows, playful decorations
- Pink/pastel color accents on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Covers, emotional impact |
| balanced | ✓✓ | Standard cute content |
| dense | ✓ | Cute knowledge cards |
| list | ✓✓ | Checklists, cute rankings |
| comparison | ✓ | Before/after transformations |
| flow | ✓ | Cute step guides |
## Best For
- Lifestyle content
- Beauty and skincare
- Fashion and style
- Daily tips and hacks
- Personal shares
@@ -1,72 +0,0 @@
---
name: fresh
category: natural
---
# Fresh Style
Clean, refreshing, natural aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | triptych
image_effects:
cutout: soft
stroke: white-solid | none
filter: clear-glow | cool-tone
typography:
decorated: none | highlight
tags: pill | white-black
direction: horizontal
decorations:
emphasis: checkmark | circle-mark
background: solid-white | solid-pastel
doodles: leaves | clouds | bubbles
frames: rounded-rect | none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Mint green, sky blue, light yellow | #9AE6B4, #90CDF4, #FAF089 |
| Background | Pure white, soft mint | #FFFFFF, #F0FFF4 |
| Accents | Leaf green, water blue | #48BB78, #4299E1 |
## Visual Elements
- Plant leaves, clouds, water drops
- Simple geometric shapes
- Breathing room, open composition
- Natural, organic elements
## Typography
- Clean, light hand lettering with breathing room
- Airy spacing
- Fresh color accents
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Clean covers |
| balanced | ✓✓ | Standard fresh content |
| dense | ✓ | Organized information |
| list | ✓ | Wellness tips |
| comparison | ✓ | Before/after health |
| flow | ✓✓ | Organic processes |
## Best For
- Health and wellness
- Minimalist lifestyle
- Self-care content
- Nature-related topics
- Clean living tips
@@ -1,72 +0,0 @@
---
name: minimal
category: elegant
---
# Minimal Style
Ultra-clean, sophisticated aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single
image_effects:
cutout: clean
stroke: none | white-solid
filter: none | muted-tones
typography:
decorated: none
tags: white-black | pill
direction: horizontal
decorations:
emphasis: underline | circle-mark
background: solid-white | solid-pastel
doodles: hand-drawn-lines
frames: none | rounded-rect
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Black, white | #000000, #FFFFFF |
| Background | Off-white, pure white | #FAFAFA, #FFFFFF |
| Accents | Single color (content-derived) | Blue, green, or coral |
## Visual Elements
- Single focal point, thin lines
- Maximum whitespace
- Simple, clean decorations
- Restrained visual elements
## Typography
- Clean, simple hand lettering
- Minimal weight variations
- Elegant spacing
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Elegant statements |
| balanced | ✓✓ | Professional content |
| dense | ✓✓ | Clean knowledge cards |
| list | ✓ | Simple lists |
| comparison | ✓ | Clean comparisons |
| flow | ✓ | Elegant processes |
## Best For
- Professional content
- Serious topics
- Elegant presentations
- High-end products
- Business content
@@ -1,73 +0,0 @@
---
name: notion
category: minimal
---
# Notion Style
Minimalist hand-drawn line art, intellectual aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: clean
stroke: none | white-solid
filter: none | muted-tones
typography:
decorated: none | handwritten
tags: black-white | pill
direction: horizontal
decorations:
emphasis: circle-mark | underline
background: solid-white | paper-texture
doodles: hand-drawn-lines | arrows-curvy
frames: none | rounded-rect
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Black, dark gray | #1A1A1A, #4A4A4A |
| Background | Pure white, off-white | #FFFFFF, #FAFAFA |
| Accents | Pastel blue, pastel yellow, pastel pink | #A8D4F0, #F9E79F, #FADBD8 |
## Visual Elements
- Simple line doodles, hand-drawn wobble effect
- Geometric shapes, stick figures
- Maximum whitespace, single-weight ink lines
- Clean, uncluttered compositions
## Typography
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Concept covers |
| balanced | ✓✓ | Standard explanations |
| dense | ✓✓ | Knowledge cards, cheat sheets |
| list | ✓✓ | Productivity tips, tool lists |
| comparison | ✓✓ | Data comparisons |
| flow | ✓✓ | Process diagrams |
## Best For
- Knowledge sharing
- Concept explanations
- SaaS content
- Productivity tips
- Tech tutorials
- Professional content
@@ -1,72 +0,0 @@
---
name: pop
category: energetic
---
# Pop Style
Vibrant, energetic, eye-catching aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | quad
image_effects:
cutout: stylized
stroke: colored-solid | double
filter: high-saturation
typography:
decorated: stroke-text | shadow-3d
tags: bubble | ribbon
direction: horizontal | curved
decorations:
emphasis: star-burst | exclamation
background: solid-saturated | dots
doodles: stars-sparkles | confetti | squiggles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Bright red, yellow, blue, green | #F56565, #ECC94B, #4299E1, #48BB78 |
| Background | White, light gray | #FFFFFF, #F7FAFC |
| Accents | Neon pink, electric purple | #FF69B4, #9F7AEA |
## Visual Elements
- Bold shapes, speech bubbles
- Comic-style effects, starburst
- Dynamic, energetic compositions
- High-energy decorations
## Typography
- Dynamic, energetic hand lettering with outlines
- Bold color combinations
- Playful, expressive forms
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Exciting announcements |
| balanced | ✓✓ | Fun tutorials |
| dense | ✓ | Packed information |
| list | ✓✓ | Fun facts lists |
| comparison | ✓✓ | Dynamic comparisons |
| flow | ✓ | Energetic processes |
## Best For
- Exciting announcements
- Fun facts
- Engaging tutorials
- Entertainment content
- Youth-oriented content
@@ -1,72 +0,0 @@
---
name: retro
category: vintage
---
# Retro Style
Vintage, nostalgic, trendy aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: stylized
stroke: dashed | double
filter: film-grain | muted-tones
typography:
decorated: brush | handwritten
tags: stamp | ribbon
direction: horizontal
decorations:
emphasis: star-burst | numbering
background: paper-texture | dots
doodles: stars-sparkles | squiggles
frames: polaroid | film-strip | stamp-border
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Muted orange, dusty pink, faded teal | #E07A4D, #D4A5A5, #6B9999 |
| Background | Aged paper, sepia tones | #F5E6D3, #E8DCC8 |
| Accents | Faded red, vintage gold | #C55A5A, #B8860B |
## Visual Elements
- Halftone dots, vintage badges
- Classic icons, tape effects
- Aged texture overlays
- Nostalgic decorative elements
## Typography
- Vintage-style hand lettering
- Classic feel with imperfections
- Aged texture on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Vintage covers |
| balanced | ✓✓ | Classic content |
| dense | ✓ | Vintage knowledge cards |
| list | ✓✓ | Classic rankings |
| comparison | ✓ | Then vs now |
| flow | ✓ | Historical timelines |
## Best For
- Throwback content
- Classic tips
- Timeless advice
- Vintage aesthetics
- Nostalgic shares
@@ -1,92 +0,0 @@
---
name: screen-print
category: poster
---
# Screen-Print Style
Bold poster art with halftone textures, limited colors, and symbolic storytelling.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: silhouette
stroke: none
filter: halftone | print-grain
typography:
decorated: stroke-text | shadow-3d
tags: none
direction: horizontal
decorations:
emphasis: star-burst | numbering
background: solid-saturated | paper-texture
doodles: none
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Burnt Orange, Deep Teal | #E8751A, #0A6E6E |
| Background | Off-Black, Warm Cream | #121212, #F5E6D0 |
| Accents | Crimson, Amber | #C0392B, #F4A623 |
**Duotone Pairs** (choose ONE based on content mood):
| Pair | Color A | Color B | Feel |
|------|---------|---------|------|
| Orange + Teal | #E8751A | #0A6E6E | Cinematic, action |
| Red + Cream | #C0392B | #F5E6D0 | Bold, classic |
| Blue + Gold | #1A3A5C | #D4A843 | Premium, prestigious |
| Crimson + Navy | #DC143C | #0D1B2A | Dramatic, noir |
| Magenta + Cyan | #C2185B | #00BCD4 | Vibrant, pop |
**Rule**: Use 2-5 colors maximum. Fewer colors = stronger impact.
## Visual Elements
- Bold silhouettes and symbolic shapes
- Halftone dot patterns within color fills
- Slight color layer misregistration (print offset effect)
- Geometric framing (circles, arches, triangles)
- Figure-ground inversion (negative space tells secondary story)
- Stencil-cut edges, no outlines — shapes defined by color boundaries
- Typography integrated as design element, not overlay
- Vintage poster border treatments
## Typography
- Bold condensed sans-serif or hand-drawn lettering
- Art Deco influences, vintage poster typography
- Typography as integral part of composition (not separate layer)
- High contrast with background for readability
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Iconic poster covers, dramatic statements |
| balanced | ✓✓ | Editorial compositions, opinion pieces |
| dense | ✗ | Too much info clashes with minimal poster aesthetic |
| list | ✓ | Bold rankings, top picks |
| comparison | ✓✓ | Duotone split compositions, before/after |
| flow | ✓ | Cinematic progression, timelines |
| mindmap | ✗ | Too complex for geometric poster style |
| quadrant | ✓✓ | Strong geometric division, classification |
## Best For
- Opinion pieces, cultural commentary
- Movie/music/book recommendations
- Dramatic announcements
- Before/after transformations
- Bold editorial content
- Event promotions
@@ -1,100 +0,0 @@
---
name: sketch-notes
category: educational
default_palette: macaron
---
# Sketch Notes Style
Hand-drawn educational infographic with slight line wobble, like a high-quality presentation visual summary.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: stylized
stroke: none
filter: none
typography:
decorated: handwritten
tags: rounded-badge
direction: horizontal
decorations:
emphasis: underline | circle-mark | arrows-curvy | star-burst
background: paper-texture
doodles: hand-drawn-lines | stars-sparkles | arrows-curvy | squiggles
frames: rounded-rect
```
## Color Palette
Default: **macaron** palette (see `palettes/macaron.md`)
When no `--palette` is specified, uses macaron colors: warm cream background (#F5F0E8), macaron blue/lavender/mint/peach zone blocks, coral red accent.
## Visual Elements
- Hand-drawn wobble on all lines and shapes
- Simple stick-figure characters at desks, working, thinking
- Rounded cards with pastel color blocks as information sections
- Color fills do NOT completely fill outlines (hand-painted feel)
- Doodle decorations: small stars, underlines, checkmarks, lock icons, clipboard icons
- Wavy hand-drawn arrows connecting zones with small text labels
- Thought bubbles and speech bubbles with sketchy outlines
- Simple conceptual icons (documents, lightbulbs, gears, arrows)
- Generous whitespace between zones for clean composition
## Typography
- Bold hand-drawn lettering for titles (large, prominent)
- Bold keywords within content zones
- Smaller annotations in secondary text color
- Hand-drawn quality on ALL text, no computer-generated fonts
- Clear information hierarchy: title > zone labels > body text > annotations
## Style Rules
### Do
- Maintain slight wobble on every line, shape, and border
- Use palette block colors as distinct section backgrounds
- Leave color fills intentionally incomplete at edges
- Include simple doodle icons relevant to content
- Keep generous whitespace between zones
- Use accent color sparingly for emphasis on key terms
- Draw connecting arrows with hand-drawn wavy feel
### Don't
- Use perfect geometric shapes or straight lines
- Create photorealistic elements
- Fill colors completely to edges (maintain hand-painted gap)
- Use dark or saturated backgrounds
- Overcrowd with too many decorative elements
- Use gradient fills or glossy effects
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓ | Simple covers with single zone |
| balanced | ✓✓ | Standard educational summaries |
| dense | ✓✓ | Knowledge cards, concept maps |
| list | ✓✓ | Step-by-step guides, checklists |
| comparison | ✓ | Side-by-side concept contrast |
| flow | ✓✓ | Process diagrams, workflows, tutorials |
| mindmap | ✓✓ | Concept maps, radial knowledge maps |
| quadrant | ✓ | Classification matrices |
## Best For
- Educational content, tutorials, how-to guides
- Process and workflow explanations
- Knowledge summaries, concept diagrams
- Technical explanations made approachable
- Visual summaries of articles or talks
- Onboarding materials, friendly guides
@@ -1,115 +0,0 @@
---
name: study-notes
category: realistic
---
# Study Notes Style
Realistic handwritten photo aesthetic - student notes style, dense and messy but readable.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single
image_effects:
cutout: none
stroke: none
filter: natural-photo
typography:
decorated: none
tags: none
direction: horizontal
decorations:
emphasis: circle-mark | underline | checkmark | cross | star-simple
background: lined-paper-white
doodles: arrows-simple | margin-notes | corrections | explanatory-diagrams
frames: none
```
## Color Palette (Three-Color Annotation System)
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Blue ballpoint, Black ink | #1E3A5F, #1A1A1A |
| Highlights | Yellow highlighter | #FFFF00 (50% opacity) |
| Accents | Red pen (circles, underlines) | #CC0000 |
| Background | White lined paper | #FFFFFF |
## Visual Elements
- Realistic photo perspective: top-down view of study desk
- Hand holding blue ballpoint pen, actively underlining
- Extremely dense handwritten content, filling entire page
- Red pen annotations: circles, underlines, stars, boxes
- Yellow highlighter marking key terms
- Correction marks, cramped notes squeezed into margins
- Simple hand-drawn symbols: → * ✓ ✗ !
- Varying pen pressure creating lighter and darker strokes
## Typography
- Authentic student handwriting
- Messy but readable, clear structure maintained
- Varying font sizes (large titles, small body, tiny margin notes)
- CJK optimized
## Content Structure
Three-section layout:
### Top Section
- Core topic (circled multiple times in red)
- First section title + 3-4 key points
- Arrow connections, red underlines
### Middle Section
- Second section title (red pen box)
- Numbered steps ①②③
- Specific methods and supplementary notes
### Bottom Section
- Third section title (red star)
- Time points / key metrics
- Key quotes / core tips (tiny corner notes)
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✗ | Not suitable - style requires dense content |
| balanced | ✓ | When content is lighter |
| dense | ✓✓ | Best fit - knowledge notes, summaries |
| list | ✓✓ | Step checklists, rankings |
| comparison | ✓ | Comparative analysis |
| flow | ✓ | Process flows |
| mindmap | ✓✓ | Mind map notes |
| quadrant | ✓ | Quadrant analysis |
## Best For
- Study guides, exam notes
- Knowledge organization, framework summaries
- Tutorial summaries, quick notes
- "Top student notes" style content
- Knowledge sharing requiring authentic feel
## Style Rules
### DO ✓
- Keep content extremely dense
- Use simple symbols (→ * ✓ ✗ !)
- Annotate key points with red pen
- Include correction marks
- Squeeze tiny notes into margins
### DON'T ✗
- Use complex emojis
- Leave too much whitespace
- Make neat, tidy layouts
- Add colorful decorations
- Include cartoon elements
@@ -1,72 +0,0 @@
---
name: warm
category: cozy
---
# Warm Style
Cozy, friendly, approachable aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: soft
stroke: white-solid | glow
filter: warm-tone | cream-skin
typography:
decorated: highlight | handwritten
tags: ribbon | bubble
direction: horizontal
decorations:
emphasis: star-burst | hearts
background: solid-pastel | gradient-radial
doodles: clouds | stars-sparkles
frames: polaroid | tape-corners
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Warm orange, golden yellow, terracotta | #ED8936, #F6AD55, #C05621 |
| Background | Cream, soft peach | #FFFAF0, #FED7AA |
| Accents | Deep brown, soft red | #744210, #E57373 |
## Visual Elements
- Sun rays, coffee cups, cozy items
- Warm lighting effects
- Friendly, inviting decorations
- Soft, comfortable shapes
## Typography
- Friendly, rounded hand lettering
- Warm color accents
- Comfortable, approachable feel
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Emotional covers |
| balanced | ✓✓ | Personal stories |
| dense | ✓ | Detailed experiences |
| list | ✓ | Life lessons |
| comparison | ✓✓ | Before/after stories |
| flow | ✓ | Journey narratives |
## Best For
- Personal stories
- Life lessons
- Emotional content
- Comfort and lifestyle
- Heartfelt shares
@@ -1,43 +0,0 @@
# Style Presets
`--preset X` expands to a style + layout + optional palette combination. Users can override any dimension.
| --preset | Style | Layout | Palette |
|----------|-------|--------|---------|
| `knowledge-card` | `notion` | `dense` | |
| `checklist` | `notion` | `list` | |
| `concept-map` | `notion` | `mindmap` | |
| `swot` | `notion` | `quadrant` | |
| `tutorial` | `chalkboard` | `flow` | |
| `classroom` | `chalkboard` | `balanced` | |
| `study-guide` | `study-notes` | `dense` | |
| `cute-share` | `cute` | `balanced` | |
| `girly` | `cute` | `sparse` | |
| `cozy-story` | `warm` | `balanced` | |
| `product-review` | `fresh` | `comparison` | |
| `nature-flow` | `fresh` | `flow` | |
| `warning` | `bold` | `list` | |
| `versus` | `bold` | `comparison` | |
| `clean-quote` | `minimal` | `sparse` | |
| `pro-summary` | `minimal` | `balanced` | |
| `retro-ranking` | `retro` | `list` | |
| `throwback` | `retro` | `balanced` | |
| `pop-facts` | `pop` | `list` | |
| `hype` | `pop` | `sparse` | |
| `poster` | `screen-print` | `sparse` | |
| `editorial` | `screen-print` | `balanced` | |
| `cinematic` | `screen-print` | `comparison` | |
| `hand-drawn-edu` | `sketch-notes` | `flow` | `macaron` |
| `sketch-card` | `sketch-notes` | `dense` | `macaron` |
| `sketch-summary` | `sketch-notes` | `balanced` | `macaron` |
Empty Palette = use style's built-in colors (or style's `default_palette` if defined in frontmatter).
## Override Examples
- `--preset knowledge-card --style chalkboard` = chalkboard style with dense layout
- `--preset poster --layout quadrant` = screen-print style with quadrant layout
- `--preset hand-drawn-edu --palette warm` = sketch-notes style with flow layout, warm palette instead of macaron
- `--style notion --palette macaron` = notion rendering rules with macaron colors
Explicit `--style`/`--layout`/`--palette` flags always override preset values.
@@ -1,198 +0,0 @@
# Xiaohongshu Content Analysis Framework
Deep analysis framework tailored for Xiaohongshu's unique engagement patterns.
## Purpose
Before creating infographics, thoroughly analyze the source material to:
- Maximize hook power and swipe motivation
- Identify save-worthy and share-worthy elements
- Plan the visual narrative arc
- Match content to optimal style/layout
## Platform Characteristics
Unlike other platforms, Xiaohongshu content must prioritize:
- **Hook Power**: First image decides 90% of engagement
- **Swipe Motivation**: Each image must compel users to continue
- **Save Value**: Content worth bookmarking for later
- **Share Triggers**: Emotional resonance that drives sharing
## Analysis Dimensions
### 1. Content Type Classification
| Type | Characteristics | Best Style | Best Layout |
|------|----------------|------------|-------------|
| 种草/安利 | Product recommendation, benefits focus | cute/fresh | balanced/list |
| 干货分享 | Knowledge, tips, how-to | notion | dense/list |
| 个人故事 | Personal experience, emotional | warm | balanced |
| 测评对比 | Review, comparison, pros/cons | bold/notion | comparison |
| 教程步骤 | Step-by-step guide | fresh/notion | flow/list |
| 避坑指南 | Warnings, mistakes to avoid | bold | list/comparison |
| 清单合集 | Collections, recommendations | cute/minimal | list/dense |
### 2. Hook Analysis (爆款标题潜力)
Evaluate title/hook potential using these patterns:
**Hook Types**:
- **数字钩子**: "5个方法", "3分钟学会", "99%的人不知道"
- **痛点钩子**: "踩过的坑", "后悔没早知道", "别再..."
- **好奇钩子**: "原来...", "竟然...", "没想到..."
- **利益钩子**: "省钱", "变美", "效率翻倍"
- **身份钩子**: "打工人必看", "学生党", "新手妈妈"
**Rating Scale**:
- ⭐⭐⭐⭐⭐ (5/5): Multiple strong hooks combined
- ⭐⭐⭐⭐ (4/5): Clear hook with room for enhancement
- ⭐⭐⭐ (3/5): Basic hook, needs strengthening
- ⭐⭐ (2/5): Weak hook, requires significant improvement
- ⭐ (1/5): No clear hook
### 3. Target Audience (用户画像)
| Audience | Interests | Preferred Style | Content Focus |
|----------|-----------|-----------------|---------------|
| 学生党 | 省钱、学习、校园 | cute/fresh | 平价、教程、学习方法 |
| 打工人 | 效率、职场、减压 | minimal/notion | 工具、技巧、摸鱼 |
| 宝妈 | 育儿、家居、省心 | warm/fresh | 实用、安全、经验 |
| 精致女孩 | 美妆、穿搭、仪式感 | cute/retro | 好看、氛围、品质 |
| 技术宅 | 工具、效率、极客 | notion/chalkboard | 深度、专业、新奇 |
| 美食爱好者 | 探店、食谱、测评 | warm/pop | 好吃、简单、颜值 |
| 旅行达人 | 攻略、打卡、小众 | fresh/retro | 省钱、避坑、拍照 |
### 4. Engagement Potential
**Save Value (收藏价值)**:
- Is it reference material? ✓ High save potential
- Is it a checklist or list? ✓ High save potential
- Is it a tutorial? ✓ High save potential
- Is it time-sensitive news? ✗ Low save potential
**Share Triggers (分享冲动)**:
- "我朋友也需要看这个" → High share potential
- "这说的就是我" → Identity resonance
- "太有用了必须分享" → Utility sharing
- "笑死,给朋友看看" → Entertainment sharing
**Comment Inducement (评论诱导)**:
- Open-ended questions: "你是哪种类型?"
- Experience sharing: "评论区说说你的经历"
- Debate triggers: "你觉得呢?"
- Help requests: "有更好的推荐吗?"
**Interaction Design (互动设计)**:
- Polls: "A还是B"
- Challenges: "你能做到几个?"
- Tags: "@你那个需要的朋友"
### 5. Visual Opportunity Map
| Content Element | Visual Treatment | Example |
|-----------------|------------------|---------|
| 数据/统计 | Highlighted numbers, simple charts | "节省80%时间" 大字突出 |
| 对比 | Before/after, side-by-side | 左右分屏对比图 |
| 步骤 | Numbered flow, arrows | 1→2→3 流程图 |
| 清单 | Checklist with icons | ✓/✗ 列表配图标 |
| 情感 | Character expressions, scenes | 卡通人物表情包 |
| 产品 | Product showcase, lifestyle | 产品实拍+使用场景 |
| 引用 | Quote cards, speech bubbles | 金句卡片设计 |
### 6. Swipe Flow Design
Plan the narrative arc across images:
| Position | Purpose | Hook Strategy |
|----------|---------|---------------|
| **Cover (封面)** | Stop scrolling | 最强视觉冲击 + 核心标题 |
| **Setup (铺垫)** | Build context | 痛点共鸣 / 好奇心 |
| **Core (核心)** | Deliver value | 干货内容,每页1-2个要点 |
| **Payoff (收获)** | Practical takeaway | 可执行的行动建议 |
| **Ending (结尾)** | Drive action | CTA + 互动引导 |
**Swipe Motivation Between Images**:
- End each image with a hook for the next
- Use "下一页更精彩" type transitions
- Create information gaps that require swiping
- Build anticipation through numbering ("第3个最重要")
## Output Format
Analysis results should be saved to `analysis.md` with:
```yaml
---
title: "5个让你效率翻倍的AI工具"
topic: 干货分享
content_type: 工具推荐
source_language: zh
user_language: zh
recommended_image_count: 6
---
## Target Audience
- **Primary**: 打工人、自由职业者 - 追求效率提升
- **Secondary**: 学生党 - 写论文、做作业需要
- **Tertiary**: 内容创作者 - 需要AI辅助
## Hook Analysis
**标题钩子评分**: ⭐⭐⭐⭐ (4/5)
- ✓ 数字钩子: "5个"
- ✓ 利益钩子: "效率翻倍"
- △ 可增强: 加入身份标签 "打工人必看"
**建议优化**:
- 原标题: "5个让你效率翻倍的AI工具"
- 优化: "打工人必看!5个让我效率翻倍的AI神器"
## Value Proposition
**为什么用户要看?**
1. **实用价值**: 直接可用的工具推荐
2. **省时省力**: 不用自己筛选,直接抄作业
3. **FOMO**: 别人都在用,我不能落后
**收藏理由**: 工具清单,需要时可以回来查
## Engagement Design
- **互动点**: 结尾问"你最常用哪个?"
- **评论诱导**: "还有什么好用的工具评论区分享"
- **分享触发**: 打工人会转发给同事
## Content Signals
- "AI工具" → notion + dense
- "效率" → notion + list
- "干货" → minimal + dense
## Swipe Flow
| Image | Position | Purpose | Hook |
|-------|----------|---------|------|
| 1 | Cover | 吸引停留 | 标题+视觉冲击 |
| 2 | Setup | 建立共鸣 | 为什么需要AI工具 |
| 3-5 | Core | 核心价值 | 每页1-2个工具详解 |
| 6 | Ending | 行动引导 | 总结+互动引导 |
## Recommended Approaches
1. **Notion + Dense** - 知识卡片风格,适合干货分享 (recommended)
2. **Notion + List** - 清爽知识卡片风格
3. **Minimal + Balanced** - 简约高端,适合职场人群
```
## Analysis Checklist
Before proceeding to outline generation:
- [ ] Can I identify the content type?
- [ ] Is the hook strong enough? (≥3 stars)
- [ ] Do I know the primary audience?
- [ ] Have I identified save/share triggers?
- [ ] Are there clear visual opportunities?
- [ ] Is the swipe flow planned?
- [ ] Have I identified the best style+layout recommendation?
@@ -1,247 +0,0 @@
# Xiaohongshu Outline Template
Template for generating infographic series outlines with layout specifications.
## File Naming
Outline files use strategy identifier in the name:
- `outline-strategy-a.md` - Story-driven variant
- `outline-strategy-b.md` - Information-dense variant
- `outline-strategy-c.md` - Visual-first variant
- `outline.md` - Final selected (copied from chosen variant)
## Image File Naming
Images use meaningful slugs for readability:
```
NN-{type}-[slug].png
NN-{type}-[slug].md (in prompts/)
```
| Type | Usage |
|------|-------|
| `cover` | First image (cover) |
| `content` | Middle content images |
| `ending` | Last image |
**Examples**:
- `01-cover-ai-tools.png`
- `02-content-why-ai.png`
- `03-content-chatgpt.png`
- `04-content-midjourney.png`
- `05-content-notion-ai.png`
- `06-ending-summary.png`
**Slug rules**:
- Derived from image content (kebab-case)
- Must be unique within the series
- Keep short but descriptive (2-4 words)
## Layout Selection Guide
### Density-Based Layouts
| Layout | When to Use | Info Points | Whitespace |
|--------|-------------|-------------|------------|
| sparse | Covers, quotes, impact statements | 1-2 | 60-70% |
| balanced | Standard content, tutorials | 3-4 | 40-50% |
| dense | Knowledge cards, cheat sheets | 5-8 | 20-30% |
### Structure-Based Layouts
| Layout | When to Use | Structure |
|--------|-------------|-----------|
| list | Rankings, checklists, steps | Numbered/bulleted vertical |
| comparison | Before/after, pros/cons | Left vs right split |
| flow | Processes, timelines | Connected nodes with arrows |
### Position-Based Recommendations
| Position | Recommended | Reasoning |
|----------|-------------|-----------|
| Cover | sparse | Maximum impact, clear title |
| Setup | balanced | Context without overwhelming |
| Core | balanced/dense/list | Match content density |
| Payoff | balanced/list | Clear takeaways |
| Ending | sparse | Clean CTA, memorable |
## Outline Format
```markdown
# Xiaohongshu Infographic Series Outline
---
strategy: a # a, b, or c
name: Story-Driven
style: notion
default_layout: dense
image_count: 6
generated: YYYY-MM-DD HH:mm
---
## Image 1 of 6
**Position**: Cover
**Layout**: sparse
**Hook**: 打工人必看!
**Slug**: ai-tools
**Filename**: 01-cover-ai-tools.png
**Text Content**:
- Title: 「5个AI神器让你效率翻倍」
- Subtitle: 亲测好用,建议收藏
**Visual Concept**:
科技感背景,多个AI工具图标环绕,中心大标题,
霓虹蓝+深色背景,未来感十足
**Swipe Hook**: 第一个就很强大👇
---
## Image 2 of 6
**Position**: Content
**Layout**: balanced
**Core Message**: 为什么你需要AI工具
**Slug**: why-ai
**Filename**: 02-content-why-ai.png
**Text Content**:
- Title: 「为什么要用AI?」
- Points:
- 重复工作自动化
- 创意辅助不卡壳
- 效率提升10倍
**Visual Concept**:
对比图:左边疲惫打工人,右边轻松使用AI的人
科技线条装饰,简洁有力
**Swipe Hook**: 接下来是具体工具推荐👇
---
## Image 3 of 6
**Position**: Content
**Layout**: dense
**Core Message**: ChatGPT使用技巧
**Slug**: chatgpt
**Filename**: 03-content-chatgpt.png
**Text Content**:
- Title: 「ChatGPT」
- Subtitle: 最强AI助手
- Points:
- 写文案:给出框架,秒出初稿
- 改文章:润色、翻译、总结
- 编程:写代码、找bug
- 学习:解释概念、出题练习
**Visual Concept**:
ChatGPT logo居中,四周放射状展示功能点
深色科技背景,霓虹绿点缀
**Swipe Hook**: 下一个更适合创意工作者👇
---
## Image 4 of 6
**Position**: Content
**Layout**: dense
**Core Message**: Midjourney绘图
**Slug**: midjourney
**Filename**: 04-content-midjourney.png
**Text Content**:
- Title: 「Midjourney」
- Subtitle: AI绘画神器
- Points:
- 输入描述,秒出图片
- 风格多样:写实/插画/3D
- 做封面、做头像、做素材
- 不会画画也能当设计师
**Visual Concept**:
展示几张MJ生成的不同风格图片
画框/画布元素装饰
**Swipe Hook**: 还有一个效率神器👇
---
## Image 5 of 6
**Position**: Content
**Layout**: balanced
**Core Message**: Notion AI笔记
**Slug**: notion-ai
**Filename**: 05-content-notion-ai.png
**Text Content**:
- Title: 「Notion AI」
- Subtitle: 智能笔记助手
- Points:
- 自动总结长文
- 头脑风暴出点子
- 整理会议记录
**Visual Concept**:
Notion界面风格,简洁黑白配色
展示笔记整理前后对比
**Swipe Hook**: 最后总结一下👇
---
## Image 6 of 6
**Position**: Ending
**Layout**: sparse
**Core Message**: 总结与互动
**Slug**: summary
**Filename**: 06-ending-summary.png
**Text Content**:
- Title: 「工具只是工具」
- Subtitle: 关键是用起来!
- CTA: 收藏备用 | 转发给需要的朋友
- Interaction: 你最常用哪个?评论区见👇
**Visual Concept**:
简洁背景,大字标题
底部互动引导文字
收藏/分享图标
---
```
## Swipe Hook Strategies
Each image should end with a hook for the next:
| Strategy | Example |
|----------|---------|
| Teaser | "第一个就很强大👇" |
| Numbering | "接下来是第2个👇" |
| Superlative | "下一个更厉害👇" |
| Question | "猜猜下一个是什么?👇" |
| Promise | "最后一个最实用👇" |
| Urgency | "最重要的来了👇" |
## Strategy Differentiation
Three strategies should differ meaningfully:
| Strategy | Focus | Structure | Page Count |
|----------|-------|-----------|------------|
| A: Story-Driven | Emotional, personal | Hook→Problem→Discovery→Experience→Conclusion | 4-6 |
| B: Information-Dense | Factual, structured | Core→Info Cards→Comparison→Recommendation | 3-5 |
| C: Visual-First | Atmospheric, minimal text | Hero→Details→Lifestyle→CTA | 3-4 |
**Example for "AI工具推荐"**:
- `outline-strategy-a.md`: Warm + Balanced - Personal journey with AI
- `outline-strategy-b.md`: Notion + Dense - Knowledge card style
- `outline-strategy-c.md`: Minimal + Sparse - Sleek tech aesthetic
@@ -1,378 +0,0 @@
# Prompt Assembly Guide
Guide for assembling image generation prompts from elements, presets, and outline content.
## Base Prompt Structure
Every XHS infographic prompt follows this structure:
```
Create a Xiaohongshu (Little Red Book) style infographic following these guidelines:
## Image Specifications
- **Type**: Infographic
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 3:4
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Main titles should be prominent and eye-catching
- Key text should be bold and enlarged
- Use highlighter effects to emphasize keywords
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language (Chinese: "",。!)
---
{STYLE_SECTION}
---
{LAYOUT_SECTION}
---
{CONTENT_SECTION}
---
{WATERMARK_SECTION}
---
Please use nano banana pro to generate the infographic based on the specifications above.
```
## Style Section Assembly
Load from `presets/{style}.md` and extract key elements:
```markdown
## Style: {style_name}
**Color Palette**:
- Primary: {colors}
- Background: {colors}
- Accents: {colors}
**Visual Elements**:
{visual_elements}
**Typography**:
{typography_style}
```
### Screen-Print Style Override
When `style: screen-print`, replace the standard Core Principles and Text Style sections with:
```
## Core Principles
- Screen print / silkscreen poster art — flat color blocks, NO gradients
- Bold silhouettes and symbolic shapes over detailed rendering
- Negative space as active storytelling element
- If content involves sensitive or copyrighted figures, create stylistically similar silhouettes
- One iconic focal point per image — conceptual, not literal
## Color Rules (CRITICAL)
- **2-5 FLAT COLORS MAXIMUM** — fewer colors = stronger impact
- Choose ONE duotone pair from preset as dominant palette
- Halftone dot patterns for tonal variation (NOT gradients)
- Slight color layer misregistration for print authenticity
## Text Style (CRITICAL)
- Bold condensed sans-serif or Art Deco influenced lettering
- Typography INTEGRATED into composition as design element
- High contrast with background, stencil-cut quality
- **DO NOT use delicate, thin, or handwritten fonts**
## Composition
- Geometric framing: circles, arches, triangles
- Figure-ground inversion where possible (negative space forms secondary image)
- Stencil-cut edges between color blocks, no outlines
- Paper grain texture beneath all colors
```
## Palette Override
When `--palette` is specified (or style has `default_palette` in frontmatter and no explicit `--palette`), palette colors **replace** the style's Color Palette in the prompt. Style rendering rules (Visual Elements, Typography, Style Rules) remain unchanged.
Load from `palettes/{palette}.md` and override:
```markdown
## Palette Override: {palette_name}
**Background**: {palette background color and hex}
**Colors**:
- Text: {text color and hex}
- Secondary: {secondary text color and hex}
- Zone 1: {zone color and hex}
- Zone 2: {zone color and hex}
- Zone 3: {zone color and hex}
- Zone 4: {zone color and hex}
- Accent: {accent color and hex}
**Constraint**: {semantic constraint from palette}
```
**Override rules**:
1. Palette Background **replaces** style's background color (keep style's texture description)
2. Palette Colors **replace** style's Color Palette section entirely
3. Palette Semantic Constraint is appended to the style section
4. If no `--palette` and style has `default_palette` → load that palette
5. If no `--palette` and no `default_palette` → use style's built-in colors (no override)
6. Explicit `--palette` always overrides style's `default_palette`
## Layout Section Assembly
Load from `elements/canvas.md` and extract relevant layout:
```markdown
## Layout: {layout_name}
**Information Density**: {density}
**Whitespace**: {percentage}
**Structure**:
{structure_description}
**Visual Balance**:
{balance_description}
```
## Content Section Assembly
From outline entry:
```markdown
## Content
**Position**: {Cover/Content/Ending}
**Core Message**: {message}
**Text Content**:
{text_list}
**Visual Concept**:
{visual_description}
```
## Watermark Section (if enabled)
```markdown
## Watermark
Include a subtle watermark "{content}" positioned at {position}. The watermark should
be legible but not distracting from the main content.
```
## Assembly Process
### Step 0: Resolve Style Preset (if `--preset` used)
If user specified `--preset`, resolve to style + layout + palette from `references/style-presets.md`:
```python
# e.g., --preset hand-drawn-edu → style=sketch-notes, layout=flow, palette=macaron
style, layout, palette = resolve_preset(preset_name)
```
Explicit `--style`/`--layout`/`--palette` flags override preset values.
### Step 1: Load Style Definition
```python
preset = load_preset(style_name) # e.g., "sketch-notes"
```
Extract:
- Color palette (may be overridden by palette)
- Visual elements
- Typography style
- Best practices (do/don't)
- `default_palette` from frontmatter (if present)
### Step 1.5: Apply Palette Override (if applicable)
```python
# Priority: explicit --palette > preset palette > style default_palette > none
palette = resolve_palette(cli_palette, preset_palette, style_default_palette)
if palette:
palette_def = load_palette(palette) # e.g., "macaron"
# Replace style colors with palette colors
# Keep style rendering rules (visual elements, typography, style rules)
```
### Step 2: Load Layout
```python
layout = get_layout_from_canvas(layout_name) # e.g., "dense"
```
Extract:
- Information density guidelines
- Whitespace percentage
- Structure description
- Visual balance rules
### Step 3: Format Content
From outline entry, format:
- Position context (Cover/Content/Ending)
- Text content with hierarchy
- Visual concept description
- Swipe hook (for context, not in prompt)
### Step 4: Add Watermark (if applicable)
If preferences include watermark:
- Add watermark section with content, position, opacity
### Step 5: Visual Consistency — Reference Image Chain
When generating multiple images in a series:
1. **Image 1 (cover)**: Generate without `--ref` — this establishes the visual anchor
2. **Images 2+**: Always pass image 1 as `--ref` to the installed image generation skill.
Read that skill's `SKILL.md` and use its documented interface rather than calling its scripts directly.
For each later image, use the assembled prompt file as input, set the output image path, keep aspect ratio `3:4`, use quality `2k`, and pass image 1 as the reference.
This ensures the AI maintains the same character design, illustration style, and color rendering across the series.
### Step 6: Combine
Assemble all sections into final prompt following base structure.
## Example: Assembled Prompt
```markdown
Create a Xiaohongshu (Little Red Book) style infographic following these guidelines:
## Image Specifications
- **Type**: Infographic
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 3:4
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Main titles should be prominent and eye-catching
- Key text should be bold and enlarged
- Use highlighter effects to emphasize keywords
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language (Chinese: "",。!)
---
## Style: Notion
**Color Palette**:
- Primary: Black (#1A1A1A), dark gray (#4A4A4A)
- Background: Pure white (#FFFFFF), off-white (#FAFAFA)
- Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
**Visual Elements**:
- Simple line doodles, hand-drawn wobble effect
- Geometric shapes, stick figures
- Maximum whitespace, single-weight ink lines
- Clean, uncluttered compositions
**Typography**:
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration on text
---
## Layout: Dense
**Information Density**: High (5-8 key points)
**Whitespace**: 20-30% of canvas
**Structure**:
- Multiple sections, structured grid
- More text, compact but organized
- Title + multiple sections with headers + numerous points
**Visual Balance**:
- Organized grid structure
- Clear section boundaries
- Compact but readable spacing
---
## Content
**Position**: Content (Page 3 of 6)
**Core Message**: ChatGPT 使用技巧
**Text Content**:
- Title: 「ChatGPT」
- Subtitle: 最强 AI 助手
- Points:
- 写文案:给出框架,秒出初稿
- 改文章:润色、翻译、总结
- 编程:写代码、找 bug
- 学习:解释概念、出题练习
**Visual Concept**:
ChatGPT logo 居中,四周放射状展示功能点
深色科技背景,霓虹绿点缀
---
## Watermark
Include a subtle watermark "@myxhsaccount" positioned at bottom-right
with approximately 50% visibility. The watermark should
be legible but not distracting from the main content.
---
Please use nano banana pro to generate the infographic based on the specifications above.
```
## Prompt Checklist
Before generating, verify:
- [ ] Style section loaded from correct preset
- [ ] Palette override applied (if `--palette` specified or style has `default_palette`)
- [ ] Layout section matches outline specification
- [ ] Content accurately reflects outline entry
- [ ] Language matches source content
- [ ] Watermark included (if enabled in preferences)
- [ ] No conflicting instructions
+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
@@ -8,7 +8,7 @@ import test from "node:test";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
const scriptPath = path.join(repoRoot, "skills", "baoyu-imagine", "scripts", "build-batch.ts");
const scriptPath = path.join(repoRoot, "skills", "baoyu-image-gen", "scripts", "build-batch.ts");
async function makeFixture(): Promise<{
root: string;
@@ -16,7 +16,7 @@ async function makeFixture(): Promise<{
promptsDir: string;
outputPath: string;
}> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "baoyu-imagine-build-batch-"));
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");
@@ -42,7 +42,7 @@ async function runBuildBatch(args: string[]): Promise<void> {
});
}
test("build-batch omits default model so baoyu-imagine can resolve env or EXTEND defaults", async () => {
test("build-batch omits default model so baoyu-image-gen can resolve env or EXTEND defaults", async () => {
const fixture = await makeFixture();
await runBuildBatch([
@@ -36,8 +36,8 @@ Options:
--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-imagine batch tasks (default: replicate)
--model <id> Explicit model for baoyu-imagine batch tasks (default: resolved by baoyu-imagine config/env)
--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)
+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;
-275
View File
@@ -1,275 +0,0 @@
---
name: baoyu-imagine
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: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-imagine
requires:
anyBins:
- bun
- npx
---
# Image Generation (AI SDK)
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
When this skill prompts the user, follow this tool-selection rule (priority order):
1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes.
## Script Directory
`{baseDir}` = this SKILL.md's directory. Main script: `{baseDir}/scripts/main.ts`. Resolve `${BUN_X}`: prefer `bun`; else `npx -y bun`; else suggest `brew install oven-sh/bun/bun`.
## Step 0: Load Preferences ⛔ BLOCKING
This step MUST complete before any image generation — generation is blocked until EXTEND.md exists.
Check these paths in order; first hit wins:
| Path | Scope |
|------|-------|
| `.baoyu-skills/baoyu-imagine/EXTEND.md` | Project |
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-imagine/EXTEND.md` | XDG |
| `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | User home |
- **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-image-gen/EXTEND.md` exists and the new path doesn't, the runtime renames it to `baoyu-imagine`. 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
Minimum working examples — see `references/usage-examples.md` for the full set including per-provider invocations and batch mode.
### Identity-preserving reference prompts
When the user wants a real person/character/object preserved from reference images, do **not** replace the reference with a long generic description. Prefer short, hard identity-preservation language:
- "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. Keep the face/proportions/hair/key accessories/overall identity from the references."
- If using multiple references, state that they are the same subject and should jointly define identity.
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
```bash
# Basic
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio and high quality
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9 --quality 2k
# Prompt from files
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference image
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
# 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
```
## Reference-Image Identity Preservation
When the user wants a person/object preserved from reference images:
- Prefer a small curated set of existing source references (usually 24) over many images; large multi-megabyte refs can destabilize streaming providers.
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
## Options
| Option | Description |
|--------|-------------|
| `--prompt <text>`, `-p` | Prompt text |
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
| `--image <path>` | Output image path (required in single-image mode) |
| `--batchfile <path>` | JSON batch file for multi-image generation |
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
| `--provider google\|openai\|azure\|openrouter\|dashscope\|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`; 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, 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 |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `OPENAI_API_KEY` | OpenAI API key |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key |
| `OPENROUTER_API_KEY` | OpenRouter API key |
| `GOOGLE_API_KEY` | Google API key |
| `DASHSCOPE_API_KEY` | DashScope API key |
| `ZAI_API_KEY` (alias `BIGMODEL_API_KEY`) | Z.AI API key |
| `MINIMAX_API_KEY` | MiniMax API key |
| `REPLICATE_API_TOKEN` | Replicate API token |
| `JIMENG_ACCESS_KEY_ID`, `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine credentials |
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
| `<PROVIDER>_IMAGE_MODEL` | Per-provider model override (`OPENAI_IMAGE_MODEL`, `GOOGLE_IMAGE_MODEL`, `DASHSCOPE_IMAGE_MODEL`, `ZAI_IMAGE_MODEL`/`BIGMODEL_IMAGE_MODEL`, `MINIMAX_IMAGE_MODEL`, `OPENROUTER_IMAGE_MODEL`, `REPLICATE_IMAGE_MODEL`, `JIMENG_IMAGE_MODEL`, `SEEDREAM_IMAGE_MODEL`) |
| `AZURE_OPENAI_DEPLOYMENT` (alias `AZURE_OPENAI_IMAGE_MODEL`) | Azure default deployment |
| `<PROVIDER>_BASE_URL` | Per-provider endpoint override |
| `AZURE_API_VERSION` | Azure image API version (default `2025-04-01-preview`) |
| `JIMENG_REGION` | Jimeng region (default `cn-north-1`) |
| `OPENAI_IMAGE_API_DIALECT` | `openai-native` \| `ratio-metadata` |
| `OPENROUTER_HTTP_REFERER`, `OPENROUTER_TITLE` | Optional OpenRouter attribution |
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`) |
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Per-provider start-gap |
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
### Codex/ChatGPT OAuth is not an OpenAI API key
`--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 (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 to `baoyu-imagine`, 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
Priority (highest → lowest) applies to every provider:
1. CLI flag `--model <id>`
2. EXTEND.md `default_model.[provider]`
3. Env var `<PROVIDER>_IMAGE_MODEL`
4. Built-in default
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.
**Display model info before each generation**:
- `Using [provider] / [model]`
- `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
## OpenAI-Compatible Gateway Dialects
`provider=openai` means the auth and routing entrypoint is OpenAI-compatible. It does **not** guarantee the upstream image API uses OpenAI native semantics. When a gateway expects a different wire format, set `default_image_api_dialect` in EXTEND.md, `OPENAI_IMAGE_API_DIALECT`, or `--imageApiDialect`:
- `openai-native`: pixel `size` (`1536x1024`) and native OpenAI quality fields
- `ratio-metadata`: aspect-ratio `size` (`16:9`) plus `metadata.resolution` (`1K|2K|4K`) and `metadata.orientation`
Use `openai-native` for the OpenAI native API or strict clones; try `ratio-metadata` for compatibility gateways in front of Gemini or similar models. Current limitation: `ratio-metadata` applies only to text-to-image; reference-image edits still need `openai-native` or a provider with first-class edit support.
## Provider-Specific Guides
Each provider has its own quirks (model families, size rules, ref support, limits). Read these when the user picks that provider or asks for non-default behavior:
| Provider | Reference |
|----------|-----------|
| DashScope (Qwen-Image families, custom sizes) | `references/providers/dashscope.md` |
| Z.AI (GLM-Image, cogview-4) | `references/providers/zai.md` |
| MiniMax (image-01, subject-reference) | `references/providers/minimax.md` |
| OpenRouter (multimodal models, `/chat/completions` flow) | `references/providers/openrouter.md` |
| Replicate (nano-banana, Seedream, Wan) | `references/providers/replicate.md` |
## Provider Selection
1. `--ref` provided + no `--provider` → auto-select Google → OpenAI → Azure → OpenRouter → Replicate → Seedream → MiniMax (MiniMax's subject reference is more specialized toward character/portrait consistency)
2. `--provider` specified → use it (if `--ref`, must be google/openai/azure/openrouter/replicate/seedream/minimax)
3. Only one API key present → use that provider
4. Multiple keys → default priority: Google → OpenAI → Azure → OpenRouter → DashScope → Z.AI → MiniMax → Replicate → Jimeng → Seedream
## Quality Presets
| Preset | Google imageSize | OpenAI size | OpenRouter size | Replicate resolution | Use case |
|--------|------------------|-------------|-----------------|----------------------|----------|
| `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: `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`
## Generation Mode
**Default**: sequential. **Batch parallel**: enabled automatically when `--batchfile` contains 2+ pending tasks.
| Situation | Prefer | Why |
|-----------|--------|-----|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead, easier debugging |
| Multiple images with saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, predictable throughput |
| Each image still needs its own reasoning / prompt writing / style exploration | Subagents | Work is still exploratory, each needs independent analysis |
| Input is `outline.md` + `prompts/` (e.g. from `baoyu-article-illustrator`) | Batch — use `scripts/build-batch.ts` to assemble the payload | The outline + prompt files already contain everything needed |
Rule of thumb: once prompt files are saved and the task is "generate all of these", prefer batch over subagents. Use subagents only when generation is coupled with per-image thinking or divergent creative exploration.
**Parallel behavior**:
- Default worker count is automatic, capped by config, built-in default 10
- Provider-specific throttling applies only in batch mode; defaults are tuned for throughput while avoiding RPM bursts
- Override with `--jobs <count>`
- Each image retries up to 3 attempts
- Final output includes success count, failure count, and per-image failure reasons
## Error Handling
- Missing API key → error with setup instructions
- Generation failure → auto-retry up to 3 attempts per image
- Invalid aspect ratio → warning, proceed with default
- Reference images with unsupported provider/model → error with fix hint
### Codex image2 fallback
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 baoyu-imagine'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 |
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
| `references/providers/openrouter.md` | OpenRouter multimodal flow |
| `references/providers/replicate.md` | Replicate supported families + guardrails |
| `references/config/preferences-schema.md` | EXTEND.md schema |
| `references/config/first-time-setup.md` | First-time setup flow |
## Extension Support
Custom configurations via EXTEND.md. See Step 0 for paths and schema.
@@ -1,35 +0,0 @@
---
name: codex-image2-fallback
description: Fallback behavior when baoyu-imagine lacks OpenAI API credentials but Codex/native image generation is available
---
# Codex Image2 Fallback
When using `baoyu-imagine` 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.
```
This is expected. The `openai` provider uses the public OpenAI Images API and needs `OPENAI_API_KEY`. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
## Practical fallback pattern
1. Try `baoyu-imagine` 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.
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
- Hermes native `image_generate`, if available.
4. Be transparent about reference-image behavior:
- 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-imagine --provider openai` can use Codex OAuth without a dedicated provider implementation.
@@ -1,18 +0,0 @@
# Codex OAuth vs OpenAI API key for baoyu-imagine
`baoyu-imagine --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`.
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-imagine'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. 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 the user wants `baoyu-imagine` 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. 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.
@@ -1,370 +0,0 @@
---
name: first-time-setup
description: First-time setup and default model selection flow for baoyu-imagine
---
# First-Time Setup
## Overview
Triggered when:
1. No EXTEND.md found → full setup (provider + model + preferences)
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
## Setup Flow
```
No EXTEND.md found EXTEND.md found, model null
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ AskUserQuestion │ │ AskUserQuestion │
│ (full setup) │ │ (model only) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Create EXTEND.md │ │ Update EXTEND.md │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
Continue Continue
```
## Flow 1: No EXTEND.md (Full Setup)
**Language**: Use user's input language or saved language preference.
Use AskUserQuestion with ALL questions in ONE call:
### Question 1: Default Provider
```yaml
header: "Provider"
question: "Default image generation provider?"
options:
- label: "Google (Recommended)"
description: "Gemini multimodal - high quality, reference images, flexible sizes"
- label: "OpenAI"
description: "GPT Image 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: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
```
### Question 2: Default Google Model
Only show if user selected Google or auto-detect (no explicit provider).
```yaml
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### Question 2b: Default OpenRouter Model
Only show if user selected OpenRouter.
```yaml
header: "OpenRouter Model"
question: "Default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
description: "Best general-purpose OpenRouter image model with reference-image workflows"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast Gemini preview model on OpenRouter"
- label: "black-forest-labs/flux.2-pro"
description: "Strong text-to-image quality through OpenRouter"
```
### Question 2c: Default Azure Deployment
Only show if user selected Azure OpenAI.
```yaml
header: "Azure Deploy"
question: "Default Azure image deployment name?"
options:
- label: "gpt-image-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
Only show if user selected MiniMax.
```yaml
header: "MiniMax Model"
question: "Default MiniMax image generation model?"
options:
- label: "image-01 (Recommended)"
description: "Best default, supports aspect ratios and custom width/height"
- label: "image-01-live"
description: "Faster variant, use aspect ratio instead of custom size"
```
### Question 2e: Default Z.AI Model
Only show if user selected Z.AI.
```yaml
header: "Z.AI Model"
question: "Default Z.AI image generation model?"
options:
- label: "glm-image (Recommended)"
description: "Best default for posters, diagrams, and text-heavy images"
- label: "cogview-4-250304"
description: "Legacy Z.AI image model on the same endpoint"
```
### Question 3: Default Quality
```yaml
header: "Quality"
question: "Default image quality?"
options:
- label: "2k (Recommended)"
description: "2048px - covers, illustrations, infographics"
- label: "normal"
description: "1024px - quick previews, drafts"
```
### Question 4: Save Location
```yaml
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project (Recommended)"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"
```
### Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| Project | `.baoyu-skills/baoyu-imagine/EXTEND.md` | Current project |
| User | `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | All projects |
### EXTEND.md Template
```yaml
---
version: 1
default_provider: [selected provider or null]
default_quality: [selected quality]
default_aspect_ratio: null
default_image_size: null
default_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
---
```
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.
### Google Model Selection
```yaml
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
```
### OpenAI Model Selection
```yaml
header: "OpenAI Model"
question: "Choose a default OpenAI image generation model?"
options:
- label: "gpt-image-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: "Earlier GPT Image model"
```
### Azure Deployment Selection
```yaml
header: "Azure Deploy"
question: "Choose a default Azure image deployment name?"
options:
- 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"
```
Notes for Azure setup:
- In `baoyu-imagine`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family.
- If the deployment name is custom, save that exact deployment name in `default_model.azure`.
### OpenRouter Model Selection
```yaml
header: "OpenRouter Model"
question: "Choose a default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
description: "Recommended for image output and reference-image edits"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast preview-oriented image generation"
- label: "black-forest-labs/flux.2-pro"
description: "High-quality text-to-image through OpenRouter"
```
### DashScope Model Selection
```yaml
header: "DashScope Model"
question: "Choose a default DashScope image generation model?"
options:
- label: "qwen-image-2.0-pro (Recommended)"
description: "Best DashScope model for text rendering and custom sizes"
- label: "qwen-image-2.0"
description: "Faster 2.0 variant with flexible output size"
- label: "qwen-image-max"
description: "Legacy Qwen model with five fixed output sizes"
- label: "qwen-image-plus"
description: "Legacy Qwen model, same current capability as qwen-image"
- label: "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"
description: "Legacy DashScope model, higher quality but slower"
```
Notes for DashScope setup:
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
- `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-imagine`, `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-imagine`, 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-2 (Recommended)"
description: "Current default for general Replicate image generation in baoyu-imagine"
- 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
```yaml
header: "MiniMax Model"
question: "Choose a default MiniMax image generation model?"
options:
- label: "image-01 (Recommended)"
description: "Best general-purpose MiniMax image model with custom width/height support"
- label: "image-01-live"
description: "Lower-latency MiniMax image model using aspect ratios"
```
Notes for MiniMax setup:
- `image-01` is the safest default. It supports official `aspect_ratio` values and documented custom `width` / `height` output sizes.
- `image-01-live` is useful when the user prefers faster generation and can work with aspect-ratio-based sizing.
- MiniMax subject reference currently uses `subject_reference[].type = character`; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB.
### Update EXTEND.md
After user selects a model:
1. Read existing EXTEND.md
2. If `default_model:` section exists → update the provider-specific key
3. If `default_model:` section missing → add the full section:
```yaml
default_model:
google: [value or null]
openai: [value or null]
azure: [value or null]
openrouter: [value or null]
dashscope: [value or null]
zai: [value or null]
minimax: [value or null]
replicate: [value or null]
```
Only set the selected provider's model; leave others as their current value or null.
## After Setup
1. Create directory if needed
2. Write/update EXTEND.md with frontmatter
3. Confirm: "Preferences saved to [path]"
4. Continue with image generation
@@ -1,135 +0,0 @@
---
name: preferences-schema
description: EXTEND.md YAML schema for baoyu-imagine user preferences
---
# Preferences Schema
## Full Schema
```yaml
---
version: 1
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)
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-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-2"
batch:
max_workers: 10
provider_limits:
replicate:
concurrency: 5
start_interval_ms: 700
google:
concurrency: 3
start_interval_ms: 1100
openai:
concurrency: 3
start_interval_ms: 1100
azure:
concurrency: 3
start_interval_ms: 1100
openrouter:
concurrency: 3
start_interval_ms: 1100
dashscope:
concurrency: 3
start_interval_ms: 1100
zai:
concurrency: 3
start_interval_ms: 1100
minimax:
concurrency: 3
start_interval_ms: 1100
---
```
## Field Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | 1 | Schema version |
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
| `default_quality` | string\|null | null | Default quality (null = 2k) |
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
| `default_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 |
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
## Examples
**Minimal**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
default_image_api_dialect: null
---
```
**Full**:
```yaml
---
version: 1
default_provider: google
default_quality: 2k
default_aspect_ratio: "16:9"
default_image_size: 2K
default_image_api_dialect: null
default_model:
google: "gemini-3-pro-image-preview"
openai: "gpt-image-2"
azure: "gpt-image-2"
openrouter: "google/gemini-3.1-flash-image-preview"
dashscope: "qwen-image-2.0-pro"
zai: "glm-image"
minimax: "image-01"
replicate: "google/nano-banana-2"
batch:
max_workers: 10
provider_limits:
replicate:
concurrency: 5
start_interval_ms: 700
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
---
```
@@ -1,69 +0,0 @@
# DashScope (阿里通义万象)
Read when the user picks `--provider dashscope`, sets `default_model.dashscope`, or asks for Qwen-Image behavior. The SKILL.md only names the default — this file covers model families, sizing rules, and limits.
## Model Families
**`qwen-image-2.0*`** — recommended modern family. Members: `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`.
- Free-form `size` in `宽*高` format
- Total pixels must be between `512*512` and `2048*2048`
- Default ≈ `1024*1024`
- Best choice for custom ratios (e.g. `21:9`) and text-heavy Chinese/English layouts
**Fixed-size family** — `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`.
- Only five sizes allowed: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
- 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-imagine 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
- `--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`
- 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-imagine 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
| Ratio | `normal` | `2k` |
|-------|----------|------|
| `1:1` | `1024*1024` | `1536*1536` |
| `2:3` | `768*1152` | `1024*1536` |
| `3:2` | `1152*768` | `1536*1024` |
| `3:4` | `960*1280` | `1080*1440` |
| `4:3` | `1280*960` | `1440*1080` |
| `9:16` | `720*1280` | `1080*1920` |
| `16:9` | `1280*720` | `1920*1080` |
| `21:9` | `1344*576` | `2048*872` |
## 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`, `watermark`, `thinking_mode`, `seed`, `bbox_list`, `enable_sequential`, and `color_palette`. `baoyu-imagine` 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)
@@ -1,29 +0,0 @@
# MiniMax
Read when the user picks `--provider minimax` or sets `default_model.minimax`. Default model is `image-01`.
## Models
**`image-01`** (recommended default)
- Supports text-to-image and subject-reference image generation
- Supports official `aspect_ratio` values: `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9`
- Supports documented custom `width` / `height` via `--size <WxH>`
- Both width and height must be in `[512, 2048]` and divisible by `8`
**`image-01-live`** — lower-latency variant
- Use `--ar` for sizing; MiniMax documents custom `width`/`height` only for `image-01`
## Subject Reference
- `--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
- Recommended refs: front-facing portraits, JPG/JPEG/PNG, under 10MB
## Official References
- [Image Generation Guide](https://platform.minimaxi.com/docs/guides/image-generation)
- [Text-to-Image API](https://platform.minimaxi.com/docs/api-reference/image-generation-t2i)
- [Image-to-Image API](https://platform.minimaxi.com/docs/api-reference/image-generation-i2i)
@@ -1,19 +0,0 @@
# OpenRouter
Read when the user picks `--provider openrouter`. Default model is `google/gemini-3.1-flash-image-preview`.
## Common Models
Use full OpenRouter model IDs:
- `google/gemini-3.1-flash-image-preview` (recommended — supports image output and reference-image workflows)
- `google/gemini-2.5-flash-image-preview`
- `black-forest-labs/flux.2-pro`
- Any other OpenRouter image-capable model ID
## Behavior Notes
- OpenRouter image generation uses `/chat/completions`, not the OpenAI `/images` endpoints
- `--ref` requires a multimodal model that supports both image input and image output
- `--imageSize` maps to `imageGenerationOptions.size`
- `--size <WxH>` is converted to the nearest supported OpenRouter size, and the aspect ratio is inferred when possible
@@ -1,50 +0,0 @@
# 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.
## Supported Families
**`google/nano-banana*`** (default: `google/nano-banana-2`)
- Supports prompt-only and reference-image generation
- Uses Replicate `aspect_ratio`, `resolution`, and `output_format`
- `--size <WxH>` is accepted only as a shorthand for a documented `aspect_ratio` plus `1K` / `2K`
**`bytedance/seedream-4.5`**
- Supports prompt-only and reference-image generation
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
- Local validation blocks unsupported `1K` requests before the API call
**`bytedance/seedream-5-lite`**
- Supports prompt-only and reference-image generation
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
- Local validation currently accepts `2K` / `3K` only
**`wan-video/wan-2.7-image`**
- Supports prompt-only and reference-image generation
- Uses Replicate `size` and `images`
- Max output is 2K
**`wan-video/wan-2.7-image-pro`**
- Supports prompt-only and reference-image generation
- Uses Replicate `size` and `images`
- 4K is allowed only for text-to-image; local validation blocks `4K + --ref`
## 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
## Examples
```bash
# Default model
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Explicit model
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
```
@@ -1,27 +0,0 @@
# Z.AI GLM-Image
Read when the user picks `--provider zai` or sets `default_model.zai`. Default model is `glm-image`.
## Models
**`glm-image`** (recommended default)
- Text-to-image only in baoyu-imagine (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`
**`cogview-4-250304`** (legacy family, same endpoint)
- Custom `--size` requires width/height in `[512, 2048]`, divisible by `16`, total pixels ≤ `2^21`
## Behavior Notes
- The sync API returns a temporary URL; baoyu-imagine 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
## Official References
- [GLM-Image Guide](https://docs.z.ai/guides/image/glm-image)
- [Generate Image API](https://docs.z.ai/api-reference/image/generate-image)
@@ -1,117 +0,0 @@
# Usage Examples
Extended CLI examples. SKILL.md shows the minimum set; read this file when the user asks about provider-specific invocation, batch generation, or less-common flags.
## Core Patterns
```bash
# Basic text-to-image
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
# High quality
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
# Prompt from files
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference images (any provider family that supports refs)
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
```
## Per-Provider
```bash
# 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-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
# OpenRouter (recommended default)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
# OpenRouter with reference
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
# DashScope (default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
# DashScope Qwen-Image 2.0 Pro (custom size, Chinese text)
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# 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
# Z.AI with custom size
${BUN_X} {baseDir}/scripts/main.ts --prompt "A science illustration with labels" --image out.png --provider zai --model glm-image --size 1472x1088
# MiniMax
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait" --image out.jpg --provider minimax
# MiniMax with subject reference (character/portrait consistency)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl by the library window" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate (default: google/nano-banana-2)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Replicate Seedream 4.5
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
```
## Batch Mode
```bash
# Batch from saved prompt files
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
# Batch with explicit worker count
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
```
### Batch File Format
```json
{
"jobs": 4,
"tasks": [
{
"id": "hero",
"promptFiles": ["prompts/hero.md"],
"image": "out/hero.png",
"provider": "replicate",
"model": "google/nano-banana-2",
"ar": "16:9",
"quality": "2k"
},
{
"id": "diagram",
"promptFiles": ["prompts/diagram.md"],
"image": "out/diagram.png",
"ref": ["references/original.png"]
}
]
}
```
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). A top-level array without the `jobs` wrapper is also accepted.
-623
View File
@@ -1,623 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs, ExtendConfig } from "./types.ts";
import {
createTaskArgs,
detectProvider,
getConfiguredMaxWorkers,
getConfiguredProviderRateLimits,
getWorkerCount,
isRetryableGenerationError,
loadBatchTasks,
loadExtendConfig,
mergeConfig,
normalizeOutputImagePath,
parseArgs,
parseOpenAIImageApiDialect,
parseSimpleYaml,
validateReferenceImages,
} from "./main.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: null,
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("parseArgs parses the main baoyu-imagine CLI flags", () => {
const args = parseArgs([
"--promptfiles",
"prompts/system.md",
"prompts/content.md",
"--image",
"out/hero",
"--provider",
"zai",
"--quality",
"2k",
"--imageSize",
"4k",
"--imageApiDialect",
"ratio-metadata",
"--ref",
"ref/one.png",
"ref/two.jpg",
"--n",
"3",
"--jobs",
"5",
"--json",
]);
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
assert.equal(args.imagePath, "out/hero");
assert.equal(args.provider, "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);
assert.equal(args.json, true);
});
test("parseArgs falls back to positional prompt and rejects invalid provider", () => {
const positional = parseArgs(["draw", "a", "cat"]);
assert.equal(positional.prompt, "draw a cat");
assert.throws(
() => parseArgs(["--provider", "stability"]),
/Invalid provider/,
);
});
test("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
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-2
zai: glm-image
azure: image-prod
minimax: image-01
batch:
max_workers: 8
provider_limits:
google:
concurrency: 2
start_interval_ms: 900
openai:
concurrency: 4
zai:
concurrency: 2
start_interval_ms: 1000
minimax:
concurrency: 2
start_interval_ms: 1400
azure:
concurrency: 1
start_interval_ms: 1500
`;
const config = parseSimpleYaml(yaml);
assert.equal(config.version, 2);
assert.equal(config.default_provider, "openrouter");
assert.equal(config.default_quality, "normal");
assert.equal(config.default_aspect_ratio, "16:9");
assert.equal(config.default_image_size, "2K");
assert.equal(config.default_image_api_dialect, "ratio-metadata");
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
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);
assert.deepEqual(config.batch?.provider_limits?.google, {
concurrency: 2,
start_interval_ms: 900,
});
assert.deepEqual(config.batch?.provider_limits?.openai, {
concurrency: 4,
});
assert.deepEqual(config.batch?.provider_limits?.zai, {
concurrency: 2,
start_interval_ms: 1000,
});
assert.deepEqual(config.batch?.provider_limits?.minimax, {
concurrency: 2,
start_interval_ms: 1400,
});
assert.deepEqual(config.batch?.provider_limits?.azure, {
concurrency: 1,
start_interval_ms: 1500,
});
});
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
const root = await makeTempDir("baoyu-imagine-extend-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "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-imagine-extend-dual-");
const cwd = path.join(root, "project");
const home = path.join(root, "home");
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "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({
provider: "openai",
quality: null,
aspectRatio: null,
imageSize: "4K",
}),
{
default_provider: "google",
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) => {
assert.throws(
() =>
detectProvider(
makeArgs({
provider: "zai",
referenceImages: ["ref.png"],
}),
),
/Reference images require a ref-capable provider/,
);
useEnv(t, {
GOOGLE_API_KEY: "google-key",
OPENAI_API_KEY: "openai-key",
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "google");
});
test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: "openai-key",
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
"openai",
);
});
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "azure");
assert.equal(
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
"azure",
);
});
test("detectProvider 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,
OPENAI_API_KEY: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: "ark-key",
});
assert.equal(
detectProvider(
makeArgs({
model: "doubao-seedream-4-5-251128",
referenceImages: ["ref.png"],
}),
),
"seedream",
);
assert.equal(
detectProvider(
makeArgs({
provider: "seedream",
referenceImages: ["ref.png"],
}),
),
"seedream",
);
});
test("detectProvider 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,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
MINIMAX_API_KEY: "minimax-key",
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "minimax");
assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax");
assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax");
});
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
useEnv(t, {
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
BAOYU_IMAGE_GEN_ZAI_CONCURRENCY: "4",
});
const extendConfig: Partial<ExtendConfig> = {
batch: {
max_workers: 7,
provider_limits: {
google: {
concurrency: 2,
start_interval_ms: 900,
},
zai: {
concurrency: 1,
start_interval_ms: 1200,
},
minimax: {
concurrency: 1,
start_interval_ms: 1500,
},
},
},
};
assert.equal(getConfiguredMaxWorkers(extendConfig), 12);
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, {
concurrency: 5,
startIntervalMs: 450,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).zai, {
concurrency: 4,
startIntervalMs: 1200,
});
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
concurrency: 1,
startIntervalMs: 1500,
});
});
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
const root = await makeTempDir("baoyu-imagine-batch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const batchFile = path.join(root, "jobs", "batch.json");
await fs.mkdir(path.dirname(batchFile), { recursive: true });
await fs.writeFile(
batchFile,
JSON.stringify({
jobs: 2,
tasks: [
{
id: "hero",
promptFiles: ["prompts/hero.md"],
image: "out/hero",
ref: ["refs/hero.png", "https://example.com/ref.png"],
ar: "16:9",
},
],
}),
);
const loaded = await loadBatchTasks(batchFile);
assert.equal(loaded.jobs, 2);
assert.equal(loaded.batchDir, path.dirname(batchFile));
assert.equal(loaded.tasks[0]?.id, "hero");
const taskArgs = createTaskArgs(
makeArgs({
provider: "replicate",
quality: "2k",
imageApiDialect: "ratio-metadata",
json: true,
}),
loaded.tasks[0]!,
loaded.batchDir,
);
assert.deepEqual(taskArgs.promptFiles, [
path.join(loaded.batchDir, "prompts/hero.md"),
]);
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
assert.deepEqual(taskArgs.referenceImages, [
path.join(loaded.batchDir, "refs/hero.png"),
"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);
});
test("path normalization, worker count, and retry classification follow expected rules", () => {
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
assert.equal(getWorkerCount(8, null, 3), 3);
assert.equal(getWorkerCount(2, 6, 5), 2);
assert.equal(getWorkerCount(5, 0, 4), 1);
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
assert.equal(
isRetryableGenerationError(
new Error("Replicate returned 2 outputs, but baoyu-imagine 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-imagine 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);
});
File diff suppressed because it is too large Load Diff
@@ -1,189 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
generateImage,
getDefaultModel,
parseAzureBaseURL,
validateArgs,
} from "./azure.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("Azure endpoint parsing and default deployment selection follow env precedence", (t) => {
assert.deepEqual(parseAzureBaseURL("https://example.openai.azure.com"), {
resourceBaseURL: "https://example.openai.azure.com/openai",
deployment: null,
});
assert.deepEqual(
parseAzureBaseURL("https://example.openai.azure.com/openai/deployments/from-url"),
{
resourceBaseURL: "https://example.openai.azure.com/openai",
deployment: "from-url",
},
);
useEnv(t, {
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/from-url",
AZURE_OPENAI_DEPLOYMENT: "explicit-deploy",
AZURE_OPENAI_IMAGE_MODEL: "env-fallback",
});
assert.equal(getDefaultModel(), "explicit-deploy");
});
test("Azure validateArgs rejects unsupported edit input formats before the API call", () => {
assert.doesNotThrow(() =>
validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.png", "photo.jpeg"] })),
);
assert.throws(
() => validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.webp"] })),
/PNG or JPG\/JPEG/,
);
});
test("Azure image generation routes model to deployment and sends mapped quality", async (t) => {
useEnv(t, {
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/default-deploy",
AZURE_API_VERSION: null,
AZURE_OPENAI_DEPLOYMENT: null,
AZURE_OPENAI_IMAGE_MODEL: null,
});
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ url: string; body: string }> = [];
globalThis.fetch = async (input, init) => {
calls.push({
url: String(input),
body: String(init?.body ?? ""),
});
return Response.json({
data: [{ b64_json: Buffer.from("azure-image").toString("base64") }],
});
};
const bytes = await generateImage(
"A calm lake at sunset",
"custom-deploy",
makeArgs({ quality: "normal" }),
);
assert.equal(Buffer.from(bytes).toString("utf8"), "azure-image");
assert.equal(
calls[0]?.url,
"https://example.openai.azure.com/openai/deployments/custom-deploy/images/generations?api-version=2025-04-01-preview",
);
const body = JSON.parse(calls[0]!.body) as Record<string, string>;
assert.equal(body.quality, "medium");
assert.equal(body.size, "1024x1024");
});
test("Azure image edits include quality in multipart requests", async (t) => {
const root = await makeTempDir("baoyu-imagine-azure-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const pngPath = path.join(root, "ref.png");
const jpgPath = path.join(root, "ref.jpg");
await fs.writeFile(pngPath, "png-bytes");
await fs.writeFile(jpgPath, "jpg-bytes");
useEnv(t, {
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
AZURE_API_VERSION: "2025-04-01-preview",
AZURE_OPENAI_DEPLOYMENT: null,
AZURE_OPENAI_IMAGE_MODEL: null,
});
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ url: string; form: FormData }> = [];
globalThis.fetch = async (input, init) => {
calls.push({
url: String(input),
form: init?.body as FormData,
});
return Response.json({
data: [{ b64_json: Buffer.from("edited-image").toString("base64") }],
});
};
const bytes = await generateImage(
"Add warm lighting",
"edit-deploy",
makeArgs({
quality: "2k",
referenceImages: [pngPath, jpgPath],
}),
);
assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image");
assert.equal(
calls[0]?.url,
"https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview",
);
assert.equal(calls[0]?.form.get("quality"), "high");
assert.equal(calls[0]?.form.get("size"), "1024x1024");
assert.equal(calls[0]?.form.getAll("image[]").length, 2);
});
@@ -1,192 +0,0 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { getOpenAISize, extractImageFromResponse } from "./openai.ts";
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
type AzureEndpoint = {
resourceBaseURL: string;
deployment: string | null;
};
const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview";
const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
export function parseAzureBaseURL(url: string): AzureEndpoint {
const parsed = new URL(url);
const trimmedPath = parsed.pathname.replace(/\/+$/, "");
const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/);
if (deploymentMatch) {
parsed.pathname = `${deploymentMatch[1] || ""}/openai`;
return {
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
deployment: decodeURIComponent(deploymentMatch[2]!),
};
}
parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`;
return {
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
deployment: null,
};
}
export function getDefaultModel(): string {
const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim();
if (explicitDeployment) return explicitDeployment;
const baseURL = process.env.AZURE_OPENAI_BASE_URL;
if (baseURL) {
try {
const { deployment } = parseAzureBaseURL(baseURL);
if (deployment) return deployment;
} catch {
// Ignore invalid URLs here so the required-env check can raise the user-facing error later.
}
}
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-2";
}
function getEndpoint(): AzureEndpoint {
const url = process.env.AZURE_OPENAI_BASE_URL;
if (!url) {
throw new Error(
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment"
);
}
return parseAzureBaseURL(url);
}
function getApiKey(): string {
const key = process.env.AZURE_OPENAI_API_KEY;
if (!key) {
throw new Error(
"AZURE_OPENAI_API_KEY is required. Get it from Azure Portal → your OpenAI resource → Keys and Endpoint."
);
}
return key;
}
function getApiVersion(): string {
return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION;
}
function getDeployment(model: string): string {
const deployment = model.trim();
if (!deployment) {
throw new Error(
"Azure deployment name is required. Use --model <deployment>, AZURE_OPENAI_DEPLOYMENT, AZURE_OPENAI_IMAGE_MODEL, or embed the deployment in AZURE_OPENAI_BASE_URL."
);
}
return deployment;
}
function buildURL(deployment: string, pathSuffix: string): string {
const { resourceBaseURL } = getEndpoint();
return `${resourceBaseURL}/deployments/${encodeURIComponent(deployment)}${pathSuffix}?api-version=${getApiVersion()}`;
}
function authHeaders(): Record<string, string> {
return { "api-key": getApiKey() };
}
function getAzureQuality(quality: CliArgs["quality"]): "medium" | "high" {
return quality === "2k" ? "high" : "medium";
}
export function validateArgs(_model: string, args: CliArgs): void {
for (const refPath of args.referenceImages) {
const ext = path.extname(refPath).toLowerCase();
if (!AZURE_EDIT_IMAGE_EXTENSIONS.has(ext)) {
throw new Error(
`Azure OpenAI reference images must be PNG or JPG/JPEG. Unsupported file: ${refPath}`
);
}
}
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const deployment = getDeployment(model);
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
if (args.referenceImages.length > 0) {
return generateWithAzureEdits(prompt, deployment, size, args.referenceImages, args.quality);
}
return generateWithAzureGenerations(prompt, deployment, size, args.quality);
}
async function generateWithAzureGenerations(
prompt: string,
deployment: string,
size: string,
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const body: Record<string, any> = {
prompt,
size,
n: 1,
quality: getAzureQuality(quality),
};
const res = await fetch(buildURL(deployment, "/images/generations"), {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Azure OpenAI API error: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
async function generateWithAzureEdits(
prompt: string,
deployment: string,
size: string,
referenceImages: string[],
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const form = new FormData();
form.append("prompt", prompt);
form.append("size", size);
form.append("n", "1");
form.append("quality", getAzureQuality(quality));
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
const filename = path.basename(refPath);
const mimeType = path.extname(filename).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
const blob = new Blob([bytes], { type: mimeType });
form.append("image[]", blob, filename);
}
const res = await fetch(buildURL(deployment, "/images/edits"), {
method: "POST",
headers: {
...authHeaders(),
},
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Azure OpenAI edits API error: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
@@ -1,392 +0,0 @@
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,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
assert.equal(getDefaultModel(), "qwen-image-max");
});
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
assert.equal(parseAspectRatio("square"), null);
assert.equal(parseAspectRatio("-1:2"), null);
});
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, 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");
});
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
});
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
});
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
const parsed = parseSize(size);
assert.ok(parsed);
assert.ok(parsed.width * parsed.height >= 512 * 512);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
});
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
assert.equal(
resolveSizeForModel("qwen-image-2.0-pro", {
size: "2048x872",
aspectRatio: null,
quality: "2k",
}),
"2048*872",
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-2.0-pro", {
size: "4096x4096",
aspectRatio: null,
quality: "2k",
}),
/total pixels between/,
);
});
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
assert.equal(
resolveSizeForModel("qwen-image-max", {
size: null,
aspectRatio: "1:1",
quality: "2k",
}),
"1328*1328",
);
assert.equal(
resolveSizeForModel("qwen-image", {
size: "1664x928",
aspectRatio: "9:16",
quality: "normal",
}),
"1664*928",
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-max", {
size: null,
aspectRatio: "21:9",
quality: "2k",
}),
/supports only fixed ratios/,
);
assert.throws(
() =>
resolveSizeForModel("qwen-image-plus", {
size: "1024x1024",
aspectRatio: null,
quality: "2k",
}),
/support only these sizes/,
);
});
test("DashScope size normalization converts WxH into provider format", () => {
assert.equal(normalizeSize("1024x1024"), "1024*1024");
assert.equal(normalizeSize("2048*1152"), "2048*1152");
});
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,625 +0,0 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs, Quality } from "../types";
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy";
type DashScopeModelSpec = {
family: DashScopeModelFamily;
defaultSize: string;
};
const DEFAULT_MODEL = "qwen-image-2.0-pro";
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
const SIZE_STEP = 16;
const QWEN_NEGATIVE_PROMPT =
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
normal: 1024 * 1024,
"2k": 1536 * 1536,
};
const 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" },
"3:2": { normal: "1152*768", "2k": "1536*1024" },
"3:4": { normal: "960*1280", "2k": "1080*1440" },
"4:3": { normal: "1280*960", "2k": "1440*1080" },
"9:16": { normal: "720*1280", "2k": "1080*1920" },
"16:9": { normal: "1280*720", "2k": "1920*1080" },
"21:9": { normal: "1344*576", "2k": "2048*872" },
};
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
"16:9": "1664*928",
"4:3": "1472*1104",
"1:1": "1328*1328",
"3:4": "1104*1472",
"9:16": "928*1664",
};
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
const LEGACY_STANDARD_SIZES: [number, number][] = [
[1024, 1024],
[1280, 720],
[720, 1280],
[1024, 768],
[768, 1024],
[1536, 1024],
[1024, 1536],
[1536, 864],
[864, 1536],
];
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
[1536, 1536],
[2048, 1152],
[1152, 2048],
[1536, 1024],
[1024, 1536],
[1536, 864],
[864, 1536],
[2048, 2048],
];
const QWEN_2_SPEC: DashScopeModelSpec = {
family: "qwen2",
defaultSize: "1024*1024",
};
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
family: "qwenFixed",
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
};
const WAN27_SPEC: DashScopeModelSpec = {
family: "wan27",
defaultSize: "2048*2048",
};
const LEGACY_SPEC: DashScopeModelSpec = {
family: "legacy",
defaultSize: "1536*1536",
};
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
"qwen-image-2.0-pro": QWEN_2_SPEC,
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
"qwen-image-2.0": QWEN_2_SPEC,
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
"qwen-image-max": QWEN_FIXED_SPEC,
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
"qwen-image-plus": QWEN_FIXED_SPEC,
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
"qwen-image": QWEN_FIXED_SPEC,
"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;
}
function getBaseUrl(): string {
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
return base.replace(/\/+$/g, "");
}
function getModelSpec(model: string): DashScopeModelSpec {
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
}
export function getModelFamily(model: string): DashScopeModelFamily {
return getModelSpec(model).family;
}
function normalizeQuality(quality: CliArgs["quality"]): Quality {
return quality === "normal" ? "normal" : "2k";
}
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
export function normalizeSize(size: string): string {
return size.replace("x", "*");
}
export function parseSize(size: string): { width: number; height: number } | null {
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function formatSize(width: number, height: number): string {
return `${width}*${height}`;
}
function getRatioValue(ar: string): number | null {
const parsed = parseAspectRatio(ar);
if (!parsed) return null;
return parsed.width / parsed.height;
}
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
const targetRatio = getRatioValue(ar);
if (targetRatio == null) return null;
let bestKey: string | null = null;
let bestDiff = Infinity;
for (const candidate of candidates) {
const candidateRatio = getRatioValue(candidate);
if (candidateRatio == null) continue;
const diff = Math.abs(candidateRatio - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
bestKey = candidate;
}
}
return bestDiff <= tolerance ? bestKey : null;
}
function roundToStep(value: number): number {
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
}
function floorToStep(value: number): number {
return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP);
}
function fitToPixelBudget(
width: number,
height: number,
minPixels: number,
maxPixels: number,
): { width: number; height: number } {
let nextWidth = width;
let nextHeight = height;
let pixels = nextWidth * nextHeight;
if (pixels > maxPixels) {
const scale = Math.sqrt(maxPixels / pixels);
nextWidth *= scale;
nextHeight *= scale;
} else if (pixels < minPixels) {
const scale = Math.sqrt(minPixels / pixels);
nextWidth *= scale;
nextHeight *= scale;
}
let roundedWidth = roundToStep(nextWidth);
let roundedHeight = roundToStep(nextHeight);
pixels = roundedWidth * roundedHeight;
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
roundedWidth -= SIZE_STEP;
} else if (roundedHeight > SIZE_STEP) {
roundedHeight -= SIZE_STEP;
} else {
break;
}
pixels = roundedWidth * roundedHeight;
}
while (pixels < minPixels) {
if (roundedWidth <= roundedHeight) {
roundedWidth += SIZE_STEP;
} else {
roundedHeight += SIZE_STEP;
}
pixels = roundedWidth * roundedHeight;
}
return { width: roundedWidth, height: roundedHeight };
}
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;
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
if (!ar) return defaultSize;
const parsed = parseAspectRatio(ar);
if (!parsed) return defaultSize;
const targetRatio = parsed.width / parsed.height;
let best = defaultSize;
let bestDiff = Infinity;
for (const [width, height] of sizes) {
const diff = Math.abs(width / height - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
best = formatSize(width, height);
}
}
return best;
}
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const normalizedQuality = normalizeQuality(quality);
if (!ar) {
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
}
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
if (recommendedRatio) {
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
}
const parsed = parseAspectRatio(ar);
if (!parsed) {
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
}
const targetRatio = parsed.width / parsed.height;
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
const rawWidth = Math.sqrt(targetPixels * targetRatio);
const rawHeight = Math.sqrt(targetPixels / targetRatio);
const fitted = fitToPixelBudget(
rawWidth,
rawHeight,
MIN_QWEN_2_TOTAL_PIXELS,
MAX_QWEN_2_TOTAL_PIXELS,
);
return formatSize(fitted.width, fitted.height);
}
function 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(
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
);
}
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
if (!ratioKey) {
throw new Error(
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
);
}
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
}
function validateSizeFormat(size: string): { width: number; height: number } {
const parsed = parseSize(size);
if (!parsed) {
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
}
return parsed;
}
function validateQwen2Size(size: string): string {
const normalized = normalizeSize(size);
const parsed = validateSizeFormat(normalized);
const totalPixels = parsed.width * parsed.height;
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
throw new Error(
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
);
}
return normalized;
}
function validateQwenFixedSize(size: string): string {
const normalized = normalizeSize(size);
validateSizeFormat(normalized);
if (!QWEN_FIXED_SIZES.includes(normalized)) {
throw new Error(
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
`Received ${normalized}.`
);
}
return normalized;
}
export function resolveSizeForModel(
model: string,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality"> & { 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);
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
validateSizeFormat(args.size);
return normalizeSize(args.size);
}
if (spec.family === "qwen2") {
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
}
if (spec.family === "qwenFixed") {
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
}
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
}
function buildParameters(
family: DashScopeModelFamily,
size: string,
): Record<string, unknown> {
if (family === "wan27") {
return {
size,
n: 1,
watermark: false,
};
}
const parameters: Record<string, unknown> = {
prompt_extend: false,
size,
};
if (family === "qwen2" || family === "qwenFixed") {
parameters.watermark = false;
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
}
return parameters;
}
type DashScopeResponse = {
output?: {
result_image?: string;
choices?: Array<{
message?: {
content?: Array<{ image?: string }>;
};
}>;
};
};
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
let imageData: string | null = null;
if (result.output?.result_image) {
imageData = result.output.result_image;
} else if (result.output?.choices?.[0]?.message?.content) {
const content = result.output.choices[0].message.content;
for (const item of content) {
if (item.image) {
imageData = item.image;
break;
}
}
}
if (!imageData) {
console.error("Response:", JSON.stringify(result, null, 2));
throw new Error("No image in response");
}
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
const imgRes = await fetch(imageData);
if (!imgRes.ok) throw new Error("Failed to download image");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
return Uint8Array.from(Buffer.from(imageData, "base64"));
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
const spec = getModelSpec(model);
if (args.referenceImages.length > 0 && spec.family !== "wan27") {
throw new Error(
"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-imagine support exactly one output image per request (extra images would be billed but discarded). Remove --n or use --n 1."
);
}
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,
},
],
},
parameters: buildParameters(spec.family, size),
};
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DashScope API error (${res.status}): ${err}`);
}
const result = await res.json() as DashScopeResponse;
return extractImageFromResponse(result);
}
@@ -1,127 +0,0 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
addAspectRatioToPrompt,
buildGoogleUrl,
buildPromptWithAspect,
extractInlineImageData,
extractPredictedImageData,
getGoogleImageSize,
isGoogleImagen,
isGoogleMultimodal,
normalizeGoogleModelId,
} from "./google.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("Google provider helpers normalize model IDs and select image size defaults", () => {
assert.equal(
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
"gemini-3.1-flash-image-preview",
);
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
});
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" });
assert.equal(
buildGoogleUrl("models/demo:generateContent"),
"https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent",
);
});
test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => {
useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" });
assert.equal(
buildGoogleUrl("/models/demo:predict"),
"https://example.com/custom/v1beta/models/demo:predict",
);
assert.equal(
addAspectRatioToPrompt("A city skyline", "16:9"),
"A city skyline Aspect ratio: 16:9.",
);
assert.equal(
buildPromptWithAspect("A city skyline", "16:9", "2k"),
"A city skyline Aspect ratio: 16:9. High resolution 2048px.",
);
});
test("Google response extractors find inline and predicted image payloads", () => {
assert.equal(
extractInlineImageData({
candidates: [
{
content: {
parts: [{ inlineData: { data: "inline-base64" } }],
},
},
],
}),
"inline-base64",
);
assert.equal(
extractPredictedImageData({
predictions: [{ image: { imageBytes: "predicted-base64" } }],
}),
"predicted-base64",
);
assert.equal(
extractPredictedImageData({
generatedImages: [{ bytesBase64Encoded: "generated-base64" }],
}),
"generated-base64",
);
});
@@ -1,349 +0,0 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = [
"gemini-3-pro-image-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-image-preview",
];
const GOOGLE_IMAGEN_MODELS = [
"imagen-3.0-generate-002",
"imagen-3.0-generate-001",
];
export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
}
export function normalizeGoogleModelId(model: string): string {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}
export function isGoogleMultimodal(model: string): boolean {
const normalized = normalizeGoogleModelId(model);
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
}
export function isGoogleImagen(model: string): boolean {
const normalized = normalizeGoogleModelId(model);
return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m));
}
function getGoogleApiKey(): string | null {
return process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
}
export function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
return args.quality === "2k" ? "2K" : "1K";
}
function getGoogleBaseUrl(): string {
const base =
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
return base.replace(/\/+$/g, "");
}
export function buildGoogleUrl(pathname: string): string {
const base = getGoogleBaseUrl();
const cleanedPath = pathname.replace(/^\/+/g, "");
if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`;
return `${base}/v1beta/${cleanedPath}`;
}
function toModelPath(model: string): string {
const modelId = normalizeGoogleModelId(model);
return `models/${modelId}`;
}
function getHttpProxy(): string | null {
return (
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
null
);
}
async function postGoogleJsonViaCurl<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const proxy = getHttpProxy();
const bodyStr = JSON.stringify(body);
const args = [
"-s",
"--connect-timeout",
"30",
"--max-time",
"300",
...(proxy ? ["-x", proxy] : []),
url,
"-H",
"Content-Type: application/json",
"-H",
`x-goog-api-key: ${apiKey}`,
"-d",
"@-",
];
let result = "";
try {
result = execFileSync("curl", args, {
input: bodyStr,
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024,
timeout: 310000,
});
} catch (error) {
const e = error as { message?: string; stderr?: string | Buffer };
const stderrText =
typeof e.stderr === "string"
? e.stderr
: e.stderr
? e.stderr.toString("utf8")
: "";
const details = stderrText.trim() || e.message || "curl request failed";
throw new Error(`Google API request failed via curl: ${details}`);
}
const parsed = JSON.parse(result) as any;
if (parsed.error) {
throw new Error(
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
);
}
return parsed as T;
}
async function postGoogleJsonViaFetch<T>(
url: string,
apiKey: string,
body: unknown,
): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Google API error (${res.status}): ${err}`);
}
return (await res.json()) as T;
}
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
const url = buildGoogleUrl(pathname);
const proxy = getHttpProxy();
// When an HTTP proxy is detected, use curl instead of fetch.
// Bun's fetch has a known issue where long-lived connections through
// HTTP proxies get their sockets closed unexpectedly, causing image
// generation requests to fail with "socket connection was closed
// unexpectedly". Using curl as the HTTP client works around this.
if (proxy) {
return postGoogleJsonViaCurl<T>(url, apiKey, body);
}
return postGoogleJsonViaFetch<T>(url, apiKey, body);
}
export function buildPromptWithAspect(
prompt: string,
ar: string | null,
quality: CliArgs["quality"],
): string {
let result = prompt;
if (ar) {
result += ` Aspect ratio: ${ar}.`;
}
if (quality === "2k") {
result += " High resolution 2048px.";
}
return result;
}
export function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
if (!ar) return prompt;
return `${prompt} Aspect ratio: ${ar}.`;
}
async function readImageAsBase64(
p: string,
): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".gif") mimeType = "image/gif";
else if (ext === ".webp") mimeType = "image/webp";
return { data: buf.toString("base64"), mimeType };
}
export function extractInlineImageData(response: {
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}): string | null {
for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) {
const data = part.inlineData?.data;
if (typeof data === "string" && data.length > 0) return data;
}
}
return null;
}
export function extractPredictedImageData(response: {
predictions?: Array<any>;
generatedImages?: Array<any>;
}): string | null {
const candidates = [
...(response.predictions || []),
...(response.generatedImages || []),
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
if (typeof candidate.bytesBase64Encoded === "string")
return candidate.bytesBase64Encoded;
if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image;
if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes;
if (typeof image.bytesBase64Encoded === "string")
return image.bytesBase64Encoded;
if (typeof image.data === "string") return image.data;
}
}
return null;
}
async function generateWithGemini(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
const parts: Array<{
text?: string;
inlineData?: { data: string; mimeType: string };
}> = [];
for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath);
parts.push({ inlineData: { data, mimeType } });
}
parts.push({ text: promptWithAspect });
const imageConfig: { imageSize: "1K" | "2K" | "4K" } = {
imageSize: getGoogleImageSize(args),
};
console.log("Generating image with Gemini...", imageConfig);
const response = await postGoogleJson<{
candidates?: Array<{
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
}>;
}>(`${toModelPath(model)}:generateContent`, {
contents: [
{
role: "user",
parts,
},
],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig,
},
});
console.log("Generation completed.");
const imageData = extractInlineImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
throw new Error("No image in response");
}
async function generateWithImagen(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
const fullPrompt = buildPromptWithAspect(
prompt,
args.aspectRatio,
args.quality,
);
const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") {
console.error(
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
);
}
const parameters: Record<string, unknown> = {
sampleCount: args.n,
};
if (args.aspectRatio) {
parameters.aspectRatio = args.aspectRatio;
}
if (imageSize === "1K" || imageSize === "2K") {
parameters.imageSize = imageSize;
} else {
parameters.imageSize = "2K";
}
const response = await postGoogleJson<{
predictions?: Array<any>;
generatedImages?: Array<any>;
}>(`${toModelPath(model)}:predict`, {
instances: [
{
prompt: fullPrompt,
},
],
parameters,
});
const imageData = extractPredictedImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
throw new Error("No image in response");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs,
): Promise<Uint8Array> {
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
return generateWithImagen(prompt, model, args);
}
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error(
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
return generateWithGemini(prompt, model, args);
}
@@ -1,115 +0,0 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import { generateImage } from "./jimeng.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
test("Jimeng submit request uses prompt field expected by current API", async (t) => {
useEnv(t, {
JIMENG_ACCESS_KEY_ID: "test-access-key",
JIMENG_SECRET_ACCESS_KEY: "test-secret-key",
JIMENG_BASE_URL: null,
JIMENG_REGION: null,
});
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{
input: string;
init?: RequestInit;
}> = [];
globalThis.fetch = async (input, init) => {
calls.push({
input: String(input),
init,
});
if (calls.length === 1) {
return Response.json({
code: 10000,
data: {
task_id: "task-123",
},
});
}
return Response.json({
code: 10000,
data: {
status: "done",
binary_data_base64: [Buffer.from("jimeng-image").toString("base64")],
},
});
};
const image = await generateImage(
"A quiet bamboo forest",
"jimeng_t2i_v40",
makeArgs({ quality: "normal" }),
);
assert.equal(Buffer.from(image).toString("utf8"), "jimeng-image");
assert.equal(calls.length, 2);
assert.equal(
calls[0]?.input,
"https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
);
const submitBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
assert.equal(submitBody.req_key, "jimeng_t2i_v40");
assert.equal(submitBody.prompt, "A quiet bamboo forest");
assert.ok(!("prompt_text" in submitBody));
assert.equal(submitBody.width, 1024);
assert.equal(submitBody.height, 1024);
});
@@ -1,467 +0,0 @@
import type { CliArgs } from "../types";
import * as crypto from "node:crypto";
type JimengSizePreset = "normal" | "2k" | "4k";
export function getDefaultModel(): string {
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
}
function getAccessKey(): string | null {
return process.env.JIMENG_ACCESS_KEY_ID || null;
}
function getSecretKey(): string | null {
return process.env.JIMENG_SECRET_ACCESS_KEY || null;
}
function getRegion(): string {
return process.env.JIMENG_REGION || "cn-north-1";
}
function getBaseUrl(): string {
return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com";
}
function resolveEndpoint(query: Record<string, string>): {
url: string;
host: string;
canonicalUri: string;
} {
let baseUrl: URL;
try {
baseUrl = new URL(getBaseUrl());
} catch {
throw new Error(`Invalid JIMENG_BASE_URL: ${getBaseUrl()}`);
}
baseUrl.search = "";
for (const [key, value] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
baseUrl.searchParams.set(key, value);
}
return {
url: baseUrl.toString(),
host: baseUrl.host,
canonicalUri: baseUrl.pathname || "/",
};
}
/**
* Volcengine HMAC-SHA256 signature generation
* Following the official documentation at:
* https://www.volcengine.com/docs/85621/1817045
*/
function generateSignature(
method: string,
query: Record<string, string>,
headers: Record<string, string>,
body: string,
accessKey: string,
secretKey: string,
region: string,
service: string,
canonicalUri: string
): string {
// 1. Create canonical request
// Sort query parameters alphabetically
const sortedQuery = Object.entries(query)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
// Sort headers alphabetically and create canonical headers
const sortedHeaders = Object.entries(headers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}\n`)
.join("");
const signedHeaders = Object.keys(headers)
.sort()
.map(k => k.toLowerCase())
.join(";");
const hashedPayload = crypto.createHash("sha256").update(body, "utf8").digest("hex");
const canonicalRequest = [
method,
canonicalUri,
sortedQuery,
sortedHeaders,
signedHeaders,
hashedPayload,
].join("\n");
const hashedCanonicalRequest = crypto
.createHash("sha256")
.update(canonicalRequest, "utf8")
.digest("hex");
// 2. Create string to sign
const algorithm = "HMAC-SHA256";
const timestamp = headers["X-Date"] || headers["x-date"];
if (!timestamp) {
throw new Error("Jimeng signature generation requires an X-Date header.");
}
const dateStamp = timestamp.slice(0, 8);
const credentialScope = `${dateStamp}/${region}/${service}/request`;
const stringToSign = [
algorithm,
timestamp,
credentialScope,
hashedCanonicalRequest,
].join("\n");
// 3. Calculate signature
const kDate = crypto
.createHmac("sha256", secretKey)
.update(dateStamp)
.digest();
const kRegion = crypto.createHmac("sha256", kDate).update(region).digest();
const kService = crypto.createHmac("sha256", kRegion).update(service).digest();
const kSigning = crypto.createHmac("sha256", kService).update("request").digest();
const signature = crypto
.createHmac("sha256", kSigning)
.update(stringToSign)
.digest("hex");
// 4. Create authorization header
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
/**
* Parse aspect ratio string like "16:9", "1:1", "4:3" into width and height
*/
function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
/**
* Supported size presets for different quality levels
* Based on Volcengine Jimeng documentation
*/
const SIZE_PRESETS: Record<string, Record<string, string>> = {
normal: {
"1:1": "1024x1024",
"4:3": "1360x1020",
"16:9": "1536x864",
"3:2": "1440x960",
"21:9": "1920x824",
},
"2k": {
"1:1": "2048x2048",
"4:3": "2304x1728",
"16:9": "2560x1440",
"3:2": "2496x1664",
"21:9": "3024x1296",
},
"4k": {
"1:1": "4096x4096",
"4:3": "4694x3520",
"16:9": "5404x3040",
"3:2": "4992x3328",
"21:9": "6198x2656",
},
};
function normalizeDimensions(value: string): string | null {
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
return `${match[1]}x${match[2]}`;
}
function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
const presets = SIZE_PRESETS[qualityLevel];
const defaultSize = presets["1:1"]!;
if (!ar) return defaultSize;
const parsed = parseAspectRatio(ar);
if (!parsed) return defaultSize;
const targetRatio = parsed.width / parsed.height;
let bestMatch = defaultSize;
let bestDiff = Infinity;
for (const [ratio, size] of Object.entries(presets)) {
const [w, h] = ratio.split(":").map(Number);
const presetRatio = w / h;
const diff = Math.abs(presetRatio - targetRatio);
if (diff < bestDiff) {
bestDiff = diff;
bestMatch = size;
}
}
return bestMatch;
}
function normalizeImageSizePreset(imageSize: string, ar: string | null): string | null {
const preset = imageSize.trim().toUpperCase();
if (preset === "1K") return getClosestPresetSize(ar, "normal");
if (preset === "2K") return getClosestPresetSize(ar, "2k");
if (preset === "4K") return getClosestPresetSize(ar, "4k");
return normalizeDimensions(imageSize);
}
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
if (imageSize) {
const normalizedSize = normalizeImageSizePreset(imageSize, ar);
if (normalizedSize) return normalizedSize;
}
// Default to 2K quality if not specified
const qualityLevel: JimengSizePreset = quality === "normal" ? "normal" : "2k";
return getClosestPresetSize(ar, qualityLevel);
}
/**
* Step 1: Submit async task to Volcengine Jimeng API
*/
async function submitTask(
prompt: string,
model: string,
size: string,
accessKey: string,
secretKey: string,
region: string
): Promise<string> {
// Query parameters for submit endpoint
const query = {
Action: "CVSync2AsyncSubmitTask",
Version: "2022-08-31",
};
const endpoint = resolveEndpoint(query);
// Request body - Jimeng API expects width/height as separate integers
const [width, height] = size.split("x").map(Number);
const bodyObj = {
req_key: model,
prompt,
// Use separate width and height parameters instead of size string
width: width,
height: height,
// Optional: seed for reproducibility
// seed: Math.floor(Math.random() * 999999),
};
const body = JSON.stringify(bodyObj);
// Headers
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
const headers = {
"Content-Type": "application/json",
"X-Date": timestampHeader,
"Host": endpoint.host,
};
// Generate signature
const authorization = generateSignature(
"POST",
query,
headers,
body,
accessKey,
secretKey,
region,
"cv",
endpoint.canonicalUri
);
console.error(`Submitting task to Jimeng (${model})...`, { width, height });
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
...headers,
"Authorization": authorization,
},
body,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Jimeng API submit error (${res.status}): ${err}`);
}
const result = (await res.json()) as {
code?: number;
message?: string;
data?: {
task_id?: string;
};
};
// Volcengine API returns code 10000 for success
if (result.code !== 10000 || !result.data?.task_id) {
console.error("Submit response:", JSON.stringify(result, null, 2));
throw new Error(`Failed to submit task: ${result.message || "Unknown error"}`);
}
return result.data.task_id;
}
/**
* Step 2: Poll for task result
* Returns image data directly as Uint8Array
*/
async function pollForResult(
taskId: string,
model: string,
accessKey: string,
secretKey: string,
region: string
): Promise<Uint8Array> {
const maxAttempts = 60;
const pollIntervalMs = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Query parameters for result endpoint
const query = {
Action: "CVSync2AsyncGetResult",
Version: "2022-08-31",
};
const endpoint = resolveEndpoint(query);
// Request body - include req_key and task_id
const bodyObj = {
req_key: model,
task_id: taskId,
};
const body = JSON.stringify(bodyObj);
// Headers
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
const headers = {
"Content-Type": "application/json",
"X-Date": timestampHeader,
"Host": endpoint.host,
};
// Generate signature
const authorization = generateSignature(
"POST",
query,
headers,
body,
accessKey,
secretKey,
region,
"cv",
endpoint.canonicalUri
);
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
...headers,
"Authorization": authorization,
},
body,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Jimeng API poll error (${res.status}): ${err}`);
}
const result = (await res.json()) as {
code?: number;
message?: string;
data?: {
status?: string;
image_urls?: string[];
binary_data_base64?: string[];
};
};
// Volcengine API returns code 10000 for success
if (result.code === 10000 && result.data) {
const { status, image_urls, binary_data_base64 } = result.data;
// Check for base64 image data (preferred by Jimeng)
if (binary_data_base64 && binary_data_base64.length > 0) {
console.error("Image received as base64 data");
const base64Data = binary_data_base64[0]!;
// Convert base64 to Uint8Array
const binaryString = Buffer.from(base64Data, "base64").toString("binary");
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Fallback to URL format
if (status === "done" && image_urls && image_urls.length > 0) {
// Download from URL
console.error(`Downloading image from ${image_urls[0]}...`);
const imgRes = await fetch(image_urls[0]!);
if (!imgRes.ok) {
throw new Error(`Failed to download image from ${image_urls[0]}`);
}
const buffer = await imgRes.arrayBuffer();
return new Uint8Array(buffer);
}
if (status === "in_queue" || status === "generating") {
console.error(`Task status: ${status} (${attempt + 1}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
continue;
}
if (status === "fail") {
throw new Error(`Jimeng task failed: ${result.message || "Generation failed"}`);
}
}
console.error("Poll response:", JSON.stringify(result, null, 2));
throw new Error(`Unexpected response during polling: ${result.message || "Unknown error"}`);
}
throw new Error("Task timeout: image generation took too long");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
if (args.referenceImages.length > 0) {
throw new Error(
"Jimeng does not support reference images. Use --provider google, openai, openrouter, or replicate."
);
}
const accessKey = getAccessKey();
const secretKey = getSecretKey();
const region = getRegion();
if (!accessKey || !secretKey) {
throw new Error(
"JIMENG_ACCESS_KEY_ID and JIMENG_SECRET_ACCESS_KEY are required. " +
"Get your credentials from https://console.volcengine.com/iam/keymanage"
);
}
const size = getImageSize(args.aspectRatio, args.quality, args.imageSize);
// Step 1: Submit task
const taskId = await submitTask(prompt, model, size, accessKey, secretKey, region);
// Step 2: Poll for result (returns image data directly)
const imageData = await pollForResult(taskId, model, accessKey, secretKey, region);
console.error("Image generation complete!");
return imageData;
}
@@ -1,175 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildMinimaxUrl,
buildRequestBody,
buildSubjectReference,
extractImageFromResponse,
parsePixelSize,
validateArgs,
} from "./minimax.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("MiniMax URL builder uses documented default and normalizes /v1 suffixes", (t) => {
useEnv(t, { MINIMAX_BASE_URL: null });
assert.equal(buildMinimaxUrl(), "https://api.minimaxi.com/v1/image_generation");
process.env.MINIMAX_BASE_URL = "https://api.minimax.io";
assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation");
process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/";
assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation");
});
test("MiniMax size parsing and validation follow documented constraints", () => {
assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 });
assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 });
assert.equal(parsePixelSize("wide"), null);
validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 }));
assert.throws(
() => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })),
/only supported with model image-01/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ size: "1537x1024" })),
/divisible by 8/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })),
/aspect_ratio must be one of/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ n: 10 })),
/at most 9 images/,
);
});
test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const refPath = path.join(dir, "portrait.png");
await fs.writeFile(refPath, Buffer.from("portrait"));
const ratioBody = await buildRequestBody(
"A portrait by the window",
"image-01",
makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }),
);
assert.equal(ratioBody.aspect_ratio, "16:9");
assert.equal(ratioBody.n, 2);
assert.equal(ratioBody.response_format, "base64");
assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/);
const sizeBody = await buildRequestBody(
"A portrait by the window",
"image-01",
makeArgs({ size: "1536x1024" }),
);
assert.equal(sizeBody.width, 1536);
assert.equal(sizeBody.height, 1024);
assert.equal(sizeBody.aspect_ratio, undefined);
});
test("MiniMax subject references require supported file types", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const good = path.join(dir, "portrait.jpg");
const bad = path.join(dir, "portrait.webp");
await fs.writeFile(good, Buffer.from("portrait"));
await fs.writeFile(bad, Buffer.from("portrait"));
const subjectReference = await buildSubjectReference([good]);
assert.equal(subjectReference?.[0]?.type, "character");
await assert.rejects(
() => buildSubjectReference([bad]),
/only supports JPG, JPEG, or PNG/,
);
});
test("MiniMax response extraction supports base64 and URL payloads", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const fromBase64 = await extractImageFromResponse({
data: {
image_base64: [Buffer.from("hello").toString("base64")],
},
});
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
globalThis.fetch = async () =>
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "image/jpeg" },
});
const fromUrl = await extractImageFromResponse({
data: {
image_urls: ["https://example.com/output.jpg"],
},
});
assert.deepEqual([...fromUrl], [1, 2, 3]);
await assert.rejects(
() => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }),
/blocked/,
);
});
@@ -1,220 +0,0 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "image-01";
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
type MinimaxSubjectReference = {
type: "character";
image_file: string;
};
type MinimaxRequestBody = {
model: string;
prompt: string;
response_format: "base64";
aspect_ratio?: string;
width?: number;
height?: number;
n?: number;
subject_reference?: MinimaxSubjectReference[];
};
type MinimaxResponse = {
id?: string;
data?: {
image_urls?: string[];
image_base64?: string[];
};
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
export function getDefaultModel(): string {
return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiKey(): string | null {
return process.env.MINIMAX_API_KEY || null;
}
export function buildMinimaxUrl(): string {
const base = (process.env.MINIMAX_BASE_URL || "https://api.minimaxi.com").replace(/\/+$/g, "");
return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`;
}
function getMimeType(filename: string): "image/jpeg" | "image/png" {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".png") return "image/png";
throw new Error(
`MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}`
);
}
export function parsePixelSize(size: string): { width: number; height: number } | null {
const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function validatePixelSize(width: number, height: number): void {
if (width < 512 || width > 2048 || height < 512 || height > 2048) {
throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
}
if (width % 8 !== 0 || height % 8 !== 0) {
throw new Error("MiniMax custom size requires width and height divisible by 8.");
}
}
export function validateArgs(model: string, args: CliArgs): void {
if (args.n > 9) {
throw new Error("MiniMax supports at most 9 images per request.");
}
if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) {
throw new Error(
`MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.`
);
}
if (args.size && !args.aspectRatio) {
if (model !== "image-01") {
throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead.");
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
}
validatePixelSize(parsed.width, parsed.height);
}
}
export async function buildSubjectReference(
referenceImages: string[],
): Promise<MinimaxSubjectReference[] | undefined> {
if (referenceImages.length === 0) return undefined;
const subjectReference: MinimaxSubjectReference[] = [];
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
if (bytes.length > MAX_REFERENCE_IMAGE_BYTES) {
throw new Error(`MiniMax subject_reference images must be smaller than 10MB: ${refPath}`);
}
subjectReference.push({
type: "character",
image_file: `data:${getMimeType(refPath)};base64,${bytes.toString("base64")}`,
});
}
return subjectReference;
}
export async function buildRequestBody(
prompt: string,
model: string,
args: CliArgs,
): Promise<MinimaxRequestBody> {
validateArgs(model, args);
const body: MinimaxRequestBody = {
model,
prompt,
response_format: "base64",
};
if (args.aspectRatio) {
body.aspect_ratio = args.aspectRatio;
} else if (args.size) {
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
}
body.width = parsed.width;
body.height = parsed.height;
}
if (args.n > 1) {
body.n = args.n;
}
const subjectReference = await buildSubjectReference(args.referenceImages);
if (subjectReference) {
body.subject_reference = subjectReference;
}
return body;
}
async function downloadImage(url: string): Promise<Uint8Array> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image from MiniMax: ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
export async function extractImageFromResponse(result: MinimaxResponse): Promise<Uint8Array> {
const baseResp = result.base_resp;
if (baseResp && baseResp.status_code !== undefined && baseResp.status_code !== 0) {
throw new Error(baseResp.status_msg || `MiniMax API returned status_code=${baseResp.status_code}`);
}
const base64Image = result.data?.image_base64?.[0];
if (base64Image) {
return Uint8Array.from(Buffer.from(base64Image, "base64"));
}
const url = result.data?.image_urls?.[0];
if (url) {
return downloadImage(url);
}
throw new Error("No image data in MiniMax response");
}
export function getDefaultOutputExtension(): ".jpg" {
return ".jpg";
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimaxi.com/");
}
const body = await buildRequestBody(prompt, model, args);
const response = await fetch(buildMinimaxUrl(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`MiniMax API error (${response.status}): ${err}`);
}
const result = (await response.json()) as MinimaxResponse;
return extractImageFromResponse(result);
}
@@ -1,179 +0,0 @@
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);
assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024");
assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792");
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
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", () => {
assert.equal(getMimeType("frame.png"), "image/png");
assert.equal(getMimeType("frame.jpg"), "image/jpeg");
assert.equal(getMimeType("frame.webp"), "image/webp");
assert.equal(getMimeType("frame.gif"), "image/gif");
});
test("OpenAI response extraction supports base64 and URL download flows", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const fromBase64 = await extractImageFromResponse({
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
});
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
globalThis.fetch = async () =>
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "application/octet-stream" },
});
const fromUrl = await extractImageFromResponse({
data: [{ url: "https://example.com/image.png" }],
});
assert.deepEqual([...fromUrl], [1, 2, 3]);
await assert.rejects(
() => extractImageFromResponse({ data: [{}] }),
/No image in response/,
);
});
@@ -1,444 +0,0 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs, OpenAIImageApiDialect } from "../types";
export function getDefaultModel(): string {
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-2";
}
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
type SizeMapping = {
square: string;
landscape: string;
portrait: string;
};
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,
quality: CliArgs["quality"]
): string {
const isDalle3 = model.includes("dall-e-3");
const isDalle2 = model.includes("dall-e-2");
if (isDalle2) {
return "1024x1024";
}
if (isGptImage2Model(model)) {
return buildGptImage2SizeFromAspectRatio(ar, quality);
}
const sizes: SizeMapping = isDalle3
? {
square: "1024x1024",
landscape: "1792x1024",
portrait: "1024x1792",
}
: {
square: "1024x1024",
landscape: "1536x1024",
portrait: "1024x1536",
};
if (!ar) return sizes.square;
const parsed = parseAspectRatio(ar);
if (!parsed) return sizes.square;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) return sizes.square;
if (ratio > 1.5) return sizes.landscape;
if (ratio < 0.67) return sizes.portrait;
return sizes.square;
}
function parsePixelSize(value: string): { width: number; height: number } | null {
const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y !== 0) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
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,
args: CliArgs
): Promise<Uint8Array> {
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error(
"OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script."
);
}
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
}
const imageApiDialect = getOpenAIImageApiDialect(args);
if (args.referenceImages.length > 0) {
if (imageApiDialect !== "openai-native") {
throw new Error(
"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,
buildOpenAIGenerationsBody(prompt, model, args)
);
}
async function generateWithChatCompletions(
baseURL: string,
apiKey: string,
prompt: string,
model: string
): Promise<Uint8Array> {
const res = await fetch(`${baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };
const content = result.choices[0]?.message?.content ?? "";
const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
if (match) {
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
}
throw new Error("No image found in chat completions response");
}
async function generateWithOpenAIGenerations(
baseURL: string,
apiKey: string,
body: OpenAIGenerationsBody
): Promise<Uint8Array> {
const res = await fetch(`${baseURL}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
async function generateWithOpenAIEdits(
baseURL: string,
apiKey: string,
prompt: string,
model: string,
size: string,
referenceImages: string[],
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
form.append("size", size);
const openAIQuality = getOpenAIQuality(model, quality);
if (openAIQuality && openAIQuality !== "standard" && openAIQuality !== "hd") {
form.append("quality", openAIQuality);
}
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
const filename = path.basename(refPath);
const mimeType = getMimeType(filename);
const blob = new Blob([bytes], { type: mimeType });
form.append("image[]", blob, filename);
}
const res = await fetch(`${baseURL}/images/edits`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI edits API error: ${err}`);
}
const result = (await res.json()) as OpenAIImageResponse;
return extractImageFromResponse(result);
}
export function getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "image/png";
}
export async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
const img = result.data[0];
if (img?.b64_json) {
return Uint8Array.from(Buffer.from(img.b64_json, "base64"));
}
if (img?.url) {
const imgRes = await fetch(img.url);
if (!imgRes.ok) throw new Error("Failed to download image");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
throw new Error("No image in response");
}

Some files were not shown because too many files have changed in this diff Show More