From 9c06b92a7442822764b22bd688c9df1f60c19c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Thu, 26 Mar 2026 09:51:48 -0500 Subject: [PATCH] chore(baoyu-image-gen): add deprecation notice redirecting to baoyu-imagine --- skills/baoyu-image-gen/SKILL.md | 408 ++++++ .../references/config/first-time-setup.md | 316 +++++ .../references/config/preferences-schema.md | 121 ++ skills/baoyu-image-gen/scripts/main.test.ts | 412 +++++++ skills/baoyu-image-gen/scripts/main.ts | 1095 +++++++++++++++++ .../scripts/providers/azure.test.ts | 188 +++ .../scripts/providers/azure.ts | 192 +++ .../scripts/providers/dashscope.test.ts | 148 +++ .../scripts/providers/dashscope.ts | 463 +++++++ .../scripts/providers/google.test.ts | 126 ++ .../scripts/providers/google.ts | 349 ++++++ .../scripts/providers/jimeng.test.ts | 114 ++ .../scripts/providers/jimeng.ts | 467 +++++++ .../scripts/providers/minimax.test.ts | 171 +++ .../scripts/providers/minimax.ts | 220 ++++ .../scripts/providers/openai.test.ts | 56 + .../scripts/providers/openai.ts | 227 ++++ .../scripts/providers/openrouter.test.ts | 168 +++ .../scripts/providers/openrouter.ts | 369 ++++++ .../scripts/providers/replicate.test.ts | 101 ++ .../scripts/providers/replicate.ts | 205 +++ .../scripts/providers/seedream.test.ts | 244 ++++ .../scripts/providers/seedream.ts | 341 +++++ skills/baoyu-image-gen/scripts/types.ts | 82 ++ 24 files changed, 6583 insertions(+) create mode 100644 skills/baoyu-image-gen/SKILL.md create mode 100644 skills/baoyu-image-gen/references/config/first-time-setup.md create mode 100644 skills/baoyu-image-gen/references/config/preferences-schema.md create mode 100644 skills/baoyu-image-gen/scripts/main.test.ts create mode 100644 skills/baoyu-image-gen/scripts/main.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/azure.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/azure.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/dashscope.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/dashscope.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/google.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/google.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/jimeng.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/jimeng.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/minimax.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/minimax.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/openai.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/openai.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/openrouter.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/openrouter.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/replicate.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/replicate.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/seedream.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/seedream.ts create mode 100644 skills/baoyu-image-gen/scripts/types.ts diff --git a/skills/baoyu-image-gen/SKILL.md b/skills/baoyu-image-gen/SKILL.md new file mode 100644 index 0000000..b76775d --- /dev/null +++ b/skills/baoyu-image-gen/SKILL.md @@ -0,0 +1,408 @@ +--- +name: baoyu-image-gen +description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images. +version: 1.56.4 +metadata: + openclaw: + homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen + requires: + anyBins: + - bun + - npx +--- + +> **⚠️ DEPRECATED**: This skill has been migrated to `baoyu-imagine`. Print a bold red warning to the user **in the user's language** (use ANSI escape `\033[1;31m`) stating that `baoyu-image-gen` has been migrated to `baoyu-imagine` and they should use `baoyu-imagine` instead. Then STOP — do NOT continue executing this skill. + +# Image Generation (AI SDK) + +Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers. + +## Script Directory + +**Agent Execution**: +1. `{baseDir}` = this SKILL.md file's directory +2. Script path = `{baseDir}/scripts/main.ts` +3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun + +## Step 0: Load Preferences ⛔ BLOCKING + +**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer. + +Check EXTEND.md existence (priority: project → user): + +```bash +# macOS, Linux, WSL, Git Bash +test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project" +test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg" +test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user" +``` + +```powershell +# PowerShell (Windows) +if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" } +$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" } +if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" } +if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" } +``` + +| Result | Action | +|--------|--------| +| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) | +| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue | + +**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created. + +| Path | Location | +|------|----------| +| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory | +| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home | + +**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits + +Schema: `references/config/preferences-schema.md` + +## Usage + +```bash +# Basic +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png + +# With aspect ratio +${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9 + +# High quality +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k + +# From prompt files +${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png + +# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0) +${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png + +# With reference images (explicit provider/model) +${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png + +# Azure OpenAI (model means deployment name) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5 + +# OpenRouter (recommended default model) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter + +# OpenRouter with reference images +${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png + +# Specific provider +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai + +# DashScope (阿里通义万象) +${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope + +# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering) +${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872 + +# DashScope legacy Qwen fixed-size model +${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928 + +# MiniMax +${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax + +# MiniMax with subject reference (best for character/portrait consistency) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9 + +# MiniMax with custom size (documented for image-01) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic poster" --image out.jpg --provider minimax --model image-01 --size 1536x1024 + +# Replicate (google/nano-banana-pro) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate + +# Replicate with specific model +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana + +# Batch mode with saved prompt files +${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json + +# Batch mode with explicit worker count +${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json +``` + +### Batch File Format + +```json +{ + "jobs": 4, + "tasks": [ + { + "id": "hero", + "promptFiles": ["prompts/hero.md"], + "image": "out/hero.png", + "provider": "replicate", + "model": "google/nano-banana-pro", + "ar": "16:9", + "quality": "2k" + }, + { + "id": "diagram", + "promptFiles": ["prompts/diagram.md"], + "image": "out/diagram.png", + "ref": ["references/original.png"] + } + ] +} +``` + +Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted. + +## Options + +| Option | Description | +|--------|-------------| +| `--prompt `, `-p` | Prompt text | +| `--promptfiles ` | Read prompt from files (concatenated) | +| `--image ` | Output image path (required in single-image mode) | +| `--batchfile ` | JSON batch file for multi-image generation | +| `--jobs ` | Worker count for batch mode (default: auto, max from config, built-in default 10) | +| `--provider google\|openai\|azure\|openrouter\|dashscope\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) | +| `--model `, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; MiniMax: `image-01`) | +| `--ar ` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) | +| `--size ` | Size (e.g., `1024x1024`) | +| `--quality normal\|2k` | Quality preset (default: `2k`) | +| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) | +| `--ref ` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 | +| `--n ` | Number of images | +| `--json` | JSON output | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `OPENAI_API_KEY` | OpenAI API key | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | +| `OPENROUTER_API_KEY` | OpenRouter API key | +| `GOOGLE_API_KEY` | Google API key | +| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) | +| `MINIMAX_API_KEY` | MiniMax API key | +| `REPLICATE_API_TOKEN` | Replicate API token | +| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key | +| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key | +| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key | +| `OPENAI_IMAGE_MODEL` | OpenAI model override | +| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | +| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible alias for Azure default deployment/model name | +| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) | +| `GOOGLE_IMAGE_MODEL` | Google model override | +| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) | +| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) | +| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) | +| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) | +| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) | +| `OPENAI_BASE_URL` | Custom OpenAI endpoint | +| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint | +| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) | +| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) | +| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution | +| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | +| `GOOGLE_BASE_URL` | Custom Google endpoint | +| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | +| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) | +| `REPLICATE_BASE_URL` | Custom Replicate endpoint | +| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) | +| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) | +| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) | +| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap | +| `BAOYU_IMAGE_GEN__CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` | +| `BAOYU_IMAGE_GEN__START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` | + +**Load Priority**: CLI args > EXTEND.md > env vars > `/.baoyu-skills/.env` > `~/.baoyu-skills/.env` + +## Model Resolution + +Model priority (highest → lowest), applies to all providers: + +1. CLI flag: `--model ` +2. EXTEND.md: `default_model.[provider]` +3. Env var: `_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`) +4. Built-in default + +For Azure, `--model` / `default_model.azure` should be the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var, and `AZURE_OPENAI_IMAGE_MODEL` remains as a backward-compatible alias. + +**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins. + +**Agent MUST display model info** before each generation: +- Show: `Using [provider] / [model]` +- Show switch hint: `Switch model: --model | EXTEND.md default_model.[provider] | env _IMAGE_MODEL` + +### DashScope Models + +Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior. + +Official DashScope model families: + +- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03` + - Free-form `size` in `宽*高` format + - Total pixels must stay between `512*512` and `2048*2048` + - Default size is approximately `1024*1024` + - Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts +- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image` + - Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664` + - Default size is `1664*928` + - `qwen-image` currently has the same capability as `qwen-image-plus` +- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1` + - Keep using them only when the user explicitly asks for legacy behavior or compatibility + +When translating CLI args into DashScope behavior: + +- `--size` wins over `--ar` +- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below +- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro` +- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee + +Recommended `qwen-image-2.0*` sizes for common aspect ratios: + +| Ratio | `normal` | `2k` | +|-------|----------|------| +| `1:1` | `1024*1024` | `1536*1536` | +| `2:3` | `768*1152` | `1024*1536` | +| `3:2` | `1152*768` | `1536*1024` | +| `3:4` | `960*1280` | `1080*1440` | +| `4:3` | `1280*960` | `1440*1080` | +| `9:16` | `720*1280` | `1080*1920` | +| `16:9` | `1280*720` | `1920*1080` | +| `21:9` | `1344*576` | `2048*872` | + +DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today. + +Official references: + +- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api) +- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image) +- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api) + +### MiniMax Models + +Use `--model image-01` or set `default_model.minimax` / `MINIMAX_IMAGE_MODEL` when the user wants MiniMax image generation. + +Official MiniMax image model options currently documented in the API reference: + +- `image-01` (recommended default) + - Supports text-to-image and subject-reference image generation + - Supports official `aspect_ratio` values: `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9` + - Supports documented custom `width` / `height` output sizes when using `--size ` + - `width` and `height` must both be between `512` and `2048`, and both must be divisible by `8` +- `image-01-live` + - Lower-latency variant + - Use `--ar` for sizing; MiniMax documents custom `width` / `height` as only effective for `image-01` + +MiniMax subject reference notes: + +- `--ref` files are sent as MiniMax `subject_reference` +- MiniMax docs currently describe `subject_reference[].type` as `character` +- Official docs say `image_file` supports public URLs or Base64 Data URLs; `baoyu-image-gen` sends local refs as Data URLs +- Official docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB + +Official references: + +- [MiniMax Image Generation Guide](https://platform.minimax.io/docs/guides/image-generation) +- [MiniMax Text-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-t2i) +- [MiniMax Image-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-i2i) + +### OpenRouter Models + +Use full OpenRouter model IDs, e.g.: + +- `google/gemini-3.1-flash-image-preview` (recommended, supports image output and reference-image workflows) +- `google/gemini-2.5-flash-image-preview` +- `black-forest-labs/flux.2-pro` +- Other OpenRouter image-capable model IDs + +Notes: + +- OpenRouter image generation uses `/chat/completions`, not the OpenAI `/images` endpoints +- If `--ref` is used, choose a multimodal model that supports image input and image output +- `--imageSize` maps to OpenRouter `imageGenerationOptions.size`; `--size ` is converted to the nearest OpenRouter size and inferred aspect ratio when possible + +### Replicate Models + +Supported model formats: + +- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro` +- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:` + +Examples: + +```bash +# Use Replicate default model +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate + +# Override model explicitly +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana +``` + +## Provider Selection + +1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Azure, then OpenRouter, then Replicate, then Seedream, then MiniMax (MiniMax subject reference is more specialized toward character/portrait consistency) +2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`) +3. Only one API key available → use that provider +4. Multiple available → default to Google + +## Quality Presets + +| Preset | Google imageSize | OpenAI Size | OpenRouter size | Replicate resolution | Use Case | +|--------|------------------|-------------|-----------------|----------------------|----------| +| `normal` | 1K | 1024px | 1K | 1K | Quick previews | +| `2k` (default) | 2K | 2048px | 2K | 2K | Covers, illustrations, infographics | + +**Google/OpenRouter imageSize**: Can be overridden with `--imageSize 1K|2K|4K` + +## Aspect Ratios + +Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1` + +- Google multimodal: uses `imageConfig.aspectRatio` +- OpenAI: maps to closest supported size +- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size ` is given, aspect ratio is inferred automatically +- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image` +- MiniMax: sends official `aspect_ratio` values directly; if `--size ` is given without `--ar`, `width` / `height` are sent for `image-01` + +## Generation Mode + +**Default**: Sequential generation. + +**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation. + +| Mode | When to Use | +|------|-------------| +| Sequential (default) | Normal usage, single images, small batches | +| Parallel batch | Batch mode with 2+ tasks | + +Execution choice: + +| Situation | Preferred approach | Why | +|-----------|--------------------|-----| +| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging | +| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput | +| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation | +| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path | + +Rule of thumb: + +- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these" +- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration + +Parallel behavior: + +- Default worker count is automatic, capped by config, built-in default 10 +- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts +- You can override worker count with `--jobs ` +- Each image retries automatically up to 3 attempts +- Final output includes success count, failure count, and per-image failure reasons + +## Error Handling + +- Missing API key → error with setup instructions +- Generation failure → auto-retry up to 3 attempts per image +- Invalid aspect ratio → warning, proceed with default +- Reference images with unsupported provider/model → error with fix hint + +## Extension Support + +Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options. diff --git a/skills/baoyu-image-gen/references/config/first-time-setup.md b/skills/baoyu-image-gen/references/config/first-time-setup.md new file mode 100644 index 0000000..ed32317 --- /dev/null +++ b/skills/baoyu-image-gen/references/config/first-time-setup.md @@ -0,0 +1,316 @@ +--- +name: first-time-setup +description: First-time setup and default model selection flow for baoyu-image-gen +--- + +# First-Time Setup + +## Overview + +Triggered when: +1. No EXTEND.md found → full setup (provider + model + preferences) +2. EXTEND.md found but `default_model.[provider]` is null → model selection only + +## Setup Flow + +``` +No EXTEND.md found EXTEND.md found, model null + │ │ + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ AskUserQuestion │ │ AskUserQuestion │ +│ (full setup) │ │ (model only) │ +└─────────────────────┘ └──────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ Create EXTEND.md │ │ Update EXTEND.md │ +└─────────────────────┘ └──────────────────────┘ + │ │ + ▼ ▼ + Continue Continue +``` + +## Flow 1: No EXTEND.md (Full Setup) + +**Language**: Use user's input language or saved language preference. + +Use AskUserQuestion with ALL questions in ONE call: + +### Question 1: Default Provider + +```yaml +header: "Provider" +question: "Default image generation provider?" +options: + - label: "Google (Recommended)" + description: "Gemini multimodal - high quality, reference images, flexible sizes" + - label: "OpenAI" + description: "GPT Image - consistent quality, reliable output" + - label: "Azure OpenAI" + description: "Azure-hosted GPT Image deployments with resource-specific routing" + - label: "OpenRouter" + description: "Router for Gemini/FLUX/OpenAI-compatible image models" + - label: "DashScope" + description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering" + - label: "MiniMax" + description: "MiniMax image generation with subject-reference character workflows" + - label: "Replicate" + description: "Community models - nano-banana-pro, flexible model selection" +``` + +### Question 2: Default Google Model + +Only show if user selected Google or auto-detect (no explicit provider). + +```yaml +header: "Google Model" +question: "Default Google image generation model?" +options: + - label: "gemini-3-pro-image-preview (Recommended)" + description: "Highest quality, best for production use" + - label: "gemini-3.1-flash-image-preview" + description: "Fast generation, good quality, lower cost" + - label: "gemini-3-flash-preview" + description: "Fast generation, balanced quality and speed" +``` + +### Question 2b: Default OpenRouter Model + +Only show if user selected OpenRouter. + +```yaml +header: "OpenRouter Model" +question: "Default OpenRouter image generation model?" +options: + - label: "google/gemini-3.1-flash-image-preview (Recommended)" + description: "Best general-purpose OpenRouter image model with reference-image workflows" + - label: "google/gemini-2.5-flash-image-preview" + description: "Fast Gemini preview model on OpenRouter" + - label: "black-forest-labs/flux.2-pro" + description: "Strong text-to-image quality through OpenRouter" +``` + +### Question 2c: Default Azure Deployment + +Only show if user selected Azure OpenAI. + +```yaml +header: "Azure Deploy" +question: "Default Azure image deployment name?" +options: + - label: "gpt-image-1.5 (Recommended)" + description: "Best default if your Azure deployment uses the same name" + - label: "gpt-image-1" + description: "Previous GPT Image deployment name" +``` + +### Question 2d: Default MiniMax Model + +Only show if user selected MiniMax. + +```yaml +header: "MiniMax Model" +question: "Default MiniMax image generation model?" +options: + - label: "image-01 (Recommended)" + description: "Best default, supports aspect ratios and custom width/height" + - label: "image-01-live" + description: "Faster variant, use aspect ratio instead of custom size" +``` + +### Question 3: Default Quality + +```yaml +header: "Quality" +question: "Default image quality?" +options: + - label: "2k (Recommended)" + description: "2048px - covers, illustrations, infographics" + - label: "normal" + description: "1024px - quick previews, drafts" +``` + +### Question 4: Save Location + +```yaml +header: "Save" +question: "Where to save preferences?" +options: + - label: "Project (Recommended)" + description: ".baoyu-skills/ (this project only)" + - label: "User" + description: "~/.baoyu-skills/ (all projects)" +``` + +### Save Locations + +| Choice | Path | Scope | +|--------|------|-------| +| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project | +| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects | + +### EXTEND.md Template + +```yaml +--- +version: 1 +default_provider: [selected provider or null] +default_quality: [selected quality] +default_aspect_ratio: null +default_image_size: null +default_model: + google: [selected google model or null] + openai: null + azure: [selected azure deployment or null] + openrouter: [selected openrouter model or null] + dashscope: null + minimax: [selected minimax model or null] + replicate: null +--- +``` + +## Flow 2: EXTEND.md Exists, Model Null + +When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider. + +### Google Model Selection + +```yaml +header: "Google Model" +question: "Choose a default Google image generation model?" +options: + - label: "gemini-3-pro-image-preview (Recommended)" + description: "Highest quality, best for production use" + - label: "gemini-3.1-flash-image-preview" + description: "Fast generation, good quality, lower cost" + - label: "gemini-3-flash-preview" + description: "Fast generation, balanced quality and speed" +``` + +### OpenAI Model Selection + +```yaml +header: "OpenAI Model" +question: "Choose a default OpenAI image generation model?" +options: + - label: "gpt-image-1.5 (Recommended)" + description: "Latest GPT Image model, high quality" + - label: "gpt-image-1" + description: "Previous generation GPT Image model" +``` + +### Azure Deployment Selection + +```yaml +header: "Azure Deploy" +question: "Choose a default Azure image deployment name?" +options: + - label: "gpt-image-1.5 (Recommended)" + description: "Use when your Azure deployment name matches the GPT-image-1.5 model" + - label: "gpt-image-1" + description: "Use when your Azure deployment name matches GPT-image-1" +``` + +Notes for Azure setup: + +- In `baoyu-image-gen`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family. +- If the deployment name is custom, save that exact deployment name in `default_model.azure`. + +### OpenRouter Model Selection + +```yaml +header: "OpenRouter Model" +question: "Choose a default OpenRouter image generation model?" +options: + - label: "google/gemini-3.1-flash-image-preview (Recommended)" + description: "Recommended for image output and reference-image edits" + - label: "google/gemini-2.5-flash-image-preview" + description: "Fast preview-oriented image generation" + - label: "black-forest-labs/flux.2-pro" + description: "High-quality text-to-image through OpenRouter" +``` + +### DashScope Model Selection + +```yaml +header: "DashScope Model" +question: "Choose a default DashScope image generation model?" +options: + - label: "qwen-image-2.0-pro (Recommended)" + description: "Best DashScope model for text rendering and custom sizes" + - label: "qwen-image-2.0" + description: "Faster 2.0 variant with flexible output size" + - label: "qwen-image-max" + description: "Legacy Qwen model with five fixed output sizes" + - label: "qwen-image-plus" + description: "Legacy Qwen model, same current capability as qwen-image" + - label: "z-image-turbo" + description: "Legacy DashScope model for compatibility" + - label: "z-image-ultra" + description: "Legacy DashScope model, higher quality but slower" +``` + +Notes for DashScope setup: + +- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering. +- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`. +- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter. + +### Replicate Model Selection + +```yaml +header: "Replicate Model" +question: "Choose a default Replicate image generation model?" +options: + - label: "google/nano-banana-pro (Recommended)" + description: "Google's fast image model on Replicate" + - label: "google/nano-banana" + description: "Google's base image model on Replicate" +``` + +### MiniMax Model Selection + +```yaml +header: "MiniMax Model" +question: "Choose a default MiniMax image generation model?" +options: + - label: "image-01 (Recommended)" + description: "Best general-purpose MiniMax image model with custom width/height support" + - label: "image-01-live" + description: "Lower-latency MiniMax image model using aspect ratios" +``` + +Notes for MiniMax setup: + +- `image-01` is the safest default. It supports official `aspect_ratio` values and documented custom `width` / `height` output sizes. +- `image-01-live` is useful when the user prefers faster generation and can work with aspect-ratio-based sizing. +- MiniMax subject reference currently uses `subject_reference[].type = character`; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB. + +### Update EXTEND.md + +After user selects a model: + +1. Read existing EXTEND.md +2. If `default_model:` section exists → update the provider-specific key +3. If `default_model:` section missing → add the full section: + +```yaml +default_model: + google: [value or null] + openai: [value or null] + azure: [value or null] + openrouter: [value or null] + dashscope: [value or null] + minimax: [value or null] + replicate: [value or null] +``` + +Only set the selected provider's model; leave others as their current value or null. + +## After Setup + +1. Create directory if needed +2. Write/update EXTEND.md with frontmatter +3. Confirm: "Preferences saved to [path]" +4. Continue with image generation diff --git a/skills/baoyu-image-gen/references/config/preferences-schema.md b/skills/baoyu-image-gen/references/config/preferences-schema.md new file mode 100644 index 0000000..9ff0a32 --- /dev/null +++ b/skills/baoyu-image-gen/references/config/preferences-schema.md @@ -0,0 +1,121 @@ +--- +name: preferences-schema +description: EXTEND.md YAML schema for baoyu-image-gen user preferences +--- + +# Preferences Schema + +## Full Schema + +```yaml +--- +version: 1 + +default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|null (null = auto-detect) + +default_quality: null # normal|2k|null (null = use default: 2k) + +default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null + +default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides quality) + +default_model: + google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview" + openai: null # e.g., "gpt-image-1.5", "gpt-image-1" + azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod" + openrouter: null # e.g., "google/gemini-3.1-flash-image-preview" + dashscope: null # e.g., "qwen-image-2.0-pro" + minimax: null # e.g., "image-01" + replicate: null # e.g., "google/nano-banana-pro" + +batch: + max_workers: 10 + provider_limits: + replicate: + concurrency: 5 + start_interval_ms: 700 + google: + concurrency: 3 + start_interval_ms: 1100 + openai: + concurrency: 3 + start_interval_ms: 1100 + azure: + concurrency: 3 + start_interval_ms: 1100 + openrouter: + concurrency: 3 + start_interval_ms: 1100 + dashscope: + concurrency: 3 + start_interval_ms: 1100 + minimax: + concurrency: 3 + start_interval_ms: 1100 +--- +``` + +## Field Reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `version` | int | 1 | Schema version | +| `default_provider` | string\|null | null | Default provider (null = auto-detect) | +| `default_quality` | string\|null | null | Default quality (null = 2k) | +| `default_aspect_ratio` | string\|null | null | Default aspect ratio | +| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) | +| `default_model.google` | string\|null | null | Google default model | +| `default_model.openai` | string\|null | null | OpenAI default model | +| `default_model.azure` | string\|null | null | Azure default deployment name | +| `default_model.openrouter` | string\|null | null | OpenRouter default model | +| `default_model.dashscope` | string\|null | null | DashScope default model | +| `default_model.minimax` | string\|null | null | MiniMax default model | +| `default_model.replicate` | string\|null | null | Replicate default model | +| `batch.max_workers` | int\|null | 10 | Batch worker cap | +| `batch.provider_limits..concurrency` | int\|null | provider default | Max simultaneous requests per provider | +| `batch.provider_limits..start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider | + +## Examples + +**Minimal**: +```yaml +--- +version: 1 +default_provider: google +default_quality: 2k +--- +``` + +**Full**: +```yaml +--- +version: 1 +default_provider: google +default_quality: 2k +default_aspect_ratio: "16:9" +default_image_size: 2K +default_model: + google: "gemini-3-pro-image-preview" + openai: "gpt-image-1.5" + azure: "gpt-image-1.5" + openrouter: "google/gemini-3.1-flash-image-preview" + dashscope: "qwen-image-2.0-pro" + minimax: "image-01" + replicate: "google/nano-banana-pro" +batch: + max_workers: 10 + provider_limits: + replicate: + concurrency: 5 + start_interval_ms: 700 + azure: + concurrency: 3 + start_interval_ms: 1100 + openrouter: + concurrency: 3 + start_interval_ms: 1100 + minimax: + concurrency: 3 + start_interval_ms: 1100 +--- +``` diff --git a/skills/baoyu-image-gen/scripts/main.test.ts b/skills/baoyu-image-gen/scripts/main.test.ts new file mode 100644 index 0000000..48baa35 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/main.test.ts @@ -0,0 +1,412 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs, ExtendConfig } from "./types.ts"; +import { + createTaskArgs, + detectProvider, + getConfiguredMaxWorkers, + getConfiguredProviderRateLimits, + getWorkerCount, + isRetryableGenerationError, + loadBatchTasks, + mergeConfig, + normalizeOutputImagePath, + parseArgs, + parseSimpleYaml, +} from "./main.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + 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 { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +test("parseArgs parses the main image-gen CLI flags", () => { + const args = parseArgs([ + "--promptfiles", + "prompts/system.md", + "prompts/content.md", + "--image", + "out/hero", + "--provider", + "openai", + "--quality", + "2k", + "--imageSize", + "4k", + "--ref", + "ref/one.png", + "ref/two.jpg", + "--n", + "3", + "--jobs", + "5", + "--json", + ]); + + assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]); + assert.equal(args.imagePath, "out/hero"); + assert.equal(args.provider, "openai"); + assert.equal(args.quality, "2k"); + assert.equal(args.imageSize, "4K"); + assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]); + assert.equal(args.n, 3); + assert.equal(args.jobs, 5); + assert.equal(args.json, true); +}); + +test("parseArgs falls back to positional prompt and rejects invalid provider", () => { + const positional = parseArgs(["draw", "a", "cat"]); + assert.equal(positional.prompt, "draw a cat"); + + assert.throws( + () => parseArgs(["--provider", "stability"]), + /Invalid provider/, + ); +}); + +test("parseSimpleYaml parses nested defaults and provider limits", () => { + const yaml = ` +version: 2 +default_provider: openrouter +default_quality: normal +default_aspect_ratio: '16:9' +default_image_size: 2K +default_model: + google: gemini-3-pro-image-preview + openai: gpt-image-1.5 + azure: image-prod + minimax: image-01 +batch: + max_workers: 8 + provider_limits: + google: + concurrency: 2 + start_interval_ms: 900 + openai: + concurrency: 4 + minimax: + concurrency: 2 + start_interval_ms: 1400 + azure: + concurrency: 1 + start_interval_ms: 1500 +`; + + const config = parseSimpleYaml(yaml); + + assert.equal(config.version, 2); + assert.equal(config.default_provider, "openrouter"); + assert.equal(config.default_quality, "normal"); + assert.equal(config.default_aspect_ratio, "16:9"); + assert.equal(config.default_image_size, "2K"); + assert.equal(config.default_model?.google, "gemini-3-pro-image-preview"); + assert.equal(config.default_model?.openai, "gpt-image-1.5"); + assert.equal(config.default_model?.azure, "image-prod"); + assert.equal(config.default_model?.minimax, "image-01"); + assert.equal(config.batch?.max_workers, 8); + assert.deepEqual(config.batch?.provider_limits?.google, { + concurrency: 2, + start_interval_ms: 900, + }); + assert.deepEqual(config.batch?.provider_limits?.openai, { + concurrency: 4, + }); + assert.deepEqual(config.batch?.provider_limits?.minimax, { + concurrency: 2, + start_interval_ms: 1400, + }); + assert.deepEqual(config.batch?.provider_limits?.azure, { + concurrency: 1, + start_interval_ms: 1500, + }); +}); + +test("mergeConfig only fills values missing from CLI args", () => { + const merged = mergeConfig( + makeArgs({ + provider: "openai", + quality: null, + aspectRatio: null, + imageSize: "4K", + }), + { + default_provider: "google", + default_quality: "2k", + default_aspect_ratio: "3:2", + default_image_size: "2K", + } satisfies Partial, + ); + + assert.equal(merged.provider, "openai"); + assert.equal(merged.quality, "2k"); + assert.equal(merged.aspectRatio, "3:2"); + assert.equal(merged.imageSize, "4K"); +}); + +test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => { + assert.throws( + () => + detectProvider( + makeArgs({ + provider: "dashscope", + referenceImages: ["ref.png"], + }), + ), + /Reference images require a ref-capable provider/, + ); + + useEnv(t, { + GOOGLE_API_KEY: "google-key", + OPENAI_API_KEY: "openai-key", + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + MINIMAX_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + assert.equal(detectProvider(makeArgs()), "google"); +}); + +test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: "openai-key", + AZURE_OPENAI_API_KEY: null, + AZURE_OPENAI_BASE_URL: null, + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + MINIMAX_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + assert.equal( + detectProvider(makeArgs({ referenceImages: ["ref.png"] })), + "openai", + ); +}); + +test("detectProvider selects Azure when only Azure credentials are configured", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: null, + AZURE_OPENAI_API_KEY: "azure-key", + AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com", + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + MINIMAX_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + + assert.equal(detectProvider(makeArgs()), "azure"); + assert.equal( + detectProvider(makeArgs({ referenceImages: ["ref.png"] })), + "azure", + ); +}); + +test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: null, + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + MINIMAX_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: "ark-key", + }); + + assert.equal( + detectProvider( + makeArgs({ + model: "doubao-seedream-4-5-251128", + referenceImages: ["ref.png"], + }), + ), + "seedream", + ); + + assert.equal( + detectProvider( + makeArgs({ + provider: "seedream", + referenceImages: ["ref.png"], + }), + ), + "seedream", + ); +}); + +test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: null, + AZURE_OPENAI_API_KEY: null, + AZURE_OPENAI_BASE_URL: null, + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + MINIMAX_API_KEY: "minimax-key", + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + + assert.equal(detectProvider(makeArgs()), "minimax"); + assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax"); + assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax"); +}); + +test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => { + useEnv(t, { + BAOYU_IMAGE_GEN_MAX_WORKERS: "12", + BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5", + BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450", + }); + + const extendConfig: Partial = { + batch: { + max_workers: 7, + provider_limits: { + google: { + concurrency: 2, + start_interval_ms: 900, + }, + minimax: { + concurrency: 1, + start_interval_ms: 1500, + }, + }, + }, + }; + + assert.equal(getConfiguredMaxWorkers(extendConfig), 12); + assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, { + concurrency: 5, + startIntervalMs: 450, + }); + assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, { + concurrency: 1, + startIntervalMs: 1500, + }); +}); + +test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => { + const root = await makeTempDir("baoyu-image-gen-batch-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const batchFile = path.join(root, "jobs", "batch.json"); + await fs.mkdir(path.dirname(batchFile), { recursive: true }); + await fs.writeFile( + batchFile, + JSON.stringify({ + jobs: 2, + tasks: [ + { + id: "hero", + promptFiles: ["prompts/hero.md"], + image: "out/hero", + ref: ["refs/hero.png"], + ar: "16:9", + }, + ], + }), + ); + + const loaded = await loadBatchTasks(batchFile); + assert.equal(loaded.jobs, 2); + assert.equal(loaded.batchDir, path.dirname(batchFile)); + assert.equal(loaded.tasks[0]?.id, "hero"); + + const taskArgs = createTaskArgs( + makeArgs({ + provider: "replicate", + quality: "2k", + json: true, + }), + loaded.tasks[0]!, + loaded.batchDir, + ); + + assert.deepEqual(taskArgs.promptFiles, [ + path.join(loaded.batchDir, "prompts/hero.md"), + ]); + assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero")); + assert.deepEqual(taskArgs.referenceImages, [ + path.join(loaded.batchDir, "refs/hero.png"), + ]); + assert.equal(taskArgs.provider, "replicate"); + assert.equal(taskArgs.aspectRatio, "16:9"); + assert.equal(taskArgs.quality, "2k"); + assert.equal(taskArgs.json, true); +}); + +test("path normalization, worker count, and retry classification follow expected rules", () => { + assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/); + assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/); + assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/); + + assert.equal(getWorkerCount(8, null, 3), 3); + assert.equal(getWorkerCount(2, 6, 5), 2); + assert.equal(getWorkerCount(5, 0, 4), 1); + + assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false); + assert.equal(isRetryableGenerationError(new Error("socket hang up")), true); +}); diff --git a/skills/baoyu-image-gen/scripts/main.ts b/skills/baoyu-image-gen/scripts/main.ts new file mode 100644 index 0000000..d76341a --- /dev/null +++ b/skills/baoyu-image-gen/scripts/main.ts @@ -0,0 +1,1095 @@ +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 type { + BatchFile, + BatchTaskInput, + CliArgs, + ExtendConfig, + Provider, +} from "./types"; + +type ProviderModule = { + getDefaultModel: () => string; + generateImage: (prompt: string, model: string, args: CliArgs) => Promise; + validateArgs?: (model: string, args: CliArgs) => void; + getDefaultOutputExtension?: (model: string, args: CliArgs) => string; +}; + +type PreparedTask = { + id: string; + prompt: string; + args: CliArgs; + provider: Provider; + model: string; + outputPath: string; + providerModule: ProviderModule; +}; + +type TaskResult = { + id: string; + provider: Provider; + model: string; + outputPath: string; + success: boolean; + attempts: number; + error: string | null; +}; + +type ProviderRateLimit = { + concurrency: number; + startIntervalMs: number; +}; + +type LoadedBatchTasks = { + tasks: BatchTaskInput[]; + jobs: number | null; + batchDir: string; +}; + +const MAX_ATTEMPTS = 3; +const DEFAULT_MAX_WORKERS = 10; +const POLL_WAIT_MS = 250; +const DEFAULT_PROVIDER_RATE_LIMITS: Record = { + replicate: { concurrency: 5, startIntervalMs: 700 }, + google: { concurrency: 3, startIntervalMs: 1100 }, + openai: { concurrency: 3, startIntervalMs: 1100 }, + openrouter: { concurrency: 3, startIntervalMs: 1100 }, + dashscope: { concurrency: 3, startIntervalMs: 1100 }, + minimax: { concurrency: 3, startIntervalMs: 1100 }, + jimeng: { concurrency: 3, startIntervalMs: 1100 }, + seedream: { concurrency: 3, startIntervalMs: 1100 }, + azure: { concurrency: 3, startIntervalMs: 1100 }, +}; + +function printUsage(): void { + console.log(`Usage: + npx -y bun scripts/main.ts --prompt "A cat" --image cat.png + npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png + npx -y bun scripts/main.ts --batchfile batch.json + +Options: + -p, --prompt Prompt text + --promptfiles Read prompt from files (concatenated) + --image Output image path (required in single-image mode) + --batchfile JSON batch file for multi-image generation + --jobs Worker count for batch mode (default: auto, max from config, built-in default 10) + --provider google|openai|openrouter|dashscope|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default) + -m, --model Model ID + --ar Aspect ratio (e.g., 16:9, 1:1, 4:3) + --size Size (e.g., 1024x1024) + --quality normal|2k Quality preset (default: 2k) + --imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality) + --ref Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0) + --n Number of images for the current task (default: 1) + --json JSON output + -h, --help Show help + +Batch file format: + { + "jobs": 4, + "tasks": [ + { + "id": "hero", + "promptFiles": ["prompts/hero.md"], + "image": "out/hero.png", + "provider": "replicate", + "model": "google/nano-banana-pro", + "ar": "16:9" + } + ] + } + +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 + +Environment variables: + OPENAI_API_KEY OpenAI API key + OPENROUTER_API_KEY OpenRouter API key + GOOGLE_API_KEY Google API key + GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY) + DASHSCOPE_API_KEY DashScope API key + MINIMAX_API_KEY MiniMax API key + REPLICATE_API_TOKEN Replicate API token + JIMENG_ACCESS_KEY_ID Jimeng Access Key ID + JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key + ARK_API_KEY Seedream/Ark API key + OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5) + 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) + MINIMAX_IMAGE_MODEL Default MiniMax model (image-01) + REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro) + JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40) + SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128) + OPENAI_BASE_URL Custom OpenAI endpoint + 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 + MINIMAX_BASE_URL Custom MiniMax endpoint + REPLICATE_BASE_URL Custom Replicate endpoint + JIMENG_BASE_URL Custom Jimeng endpoint + AZURE_OPENAI_API_KEY Azure OpenAI API key + 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) + SEEDREAM_BASE_URL Custom Seedream endpoint + BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap + BAOYU_IMAGE_GEN__CONCURRENCY Override provider concurrency + BAOYU_IMAGE_GEN__START_INTERVAL_MS Override provider start gap in ms + +Env file load order: CLI args > EXTEND.md > process.env > /.baoyu-skills/.env > ~/.baoyu-skills/.env`); +} + +export function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + }; + + const positional: string[] = []; + + const takeMany = (i: number): { items: string[]; next: number } => { + const items: string[] = []; + let j = i + 1; + while (j < argv.length) { + const v = argv[j]!; + if (v.startsWith("-")) break; + items.push(v); + j++; + } + return { items, next: j - 1 }; + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + + if (a === "--help" || a === "-h") { + out.help = true; + continue; + } + + if (a === "--json") { + out.json = true; + continue; + } + + if (a === "--prompt" || a === "-p") { + const v = argv[++i]; + if (!v) throw new Error(`Missing value for ${a}`); + out.prompt = v; + continue; + } + + if (a === "--promptfiles") { + const { items, next } = takeMany(i); + if (items.length === 0) throw new Error("Missing files for --promptfiles"); + out.promptFiles.push(...items); + i = next; + continue; + } + + if (a === "--image") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --image"); + out.imagePath = v; + continue; + } + + if (a === "--batchfile") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --batchfile"); + out.batchFile = v; + continue; + } + + if (a === "--jobs") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --jobs"); + out.jobs = parseInt(v, 10); + if (isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`); + continue; + } + + if (a === "--provider") { + const v = argv[++i]; + if ( + v !== "google" && + v !== "openai" && + v !== "openrouter" && + v !== "dashscope" && + v !== "minimax" && + v !== "replicate" && + v !== "jimeng" && + v !== "seedream" && + v !== "azure" + ) { + throw new Error(`Invalid provider: ${v}`); + } + out.provider = v; + continue; + } + + if (a === "--model" || a === "-m") { + const v = argv[++i]; + if (!v) throw new Error(`Missing value for ${a}`); + out.model = v; + continue; + } + + if (a === "--ar") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --ar"); + out.aspectRatio = v; + continue; + } + + if (a === "--size") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --size"); + out.size = v; + continue; + } + + if (a === "--quality") { + const v = argv[++i]; + if (v !== "normal" && v !== "2k") throw new Error(`Invalid quality: ${v}`); + out.quality = v; + continue; + } + + if (a === "--imageSize") { + const v = argv[++i]?.toUpperCase(); + if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`); + out.imageSize = v; + continue; + } + + if (a === "--ref" || a === "--reference") { + const { items, next } = takeMany(i); + if (items.length === 0) throw new Error(`Missing files for ${a}`); + out.referenceImages.push(...items); + i = next; + continue; + } + + if (a === "--n") { + const v = argv[++i]; + if (!v) throw new Error("Missing value for --n"); + out.n = parseInt(v, 10); + if (isNaN(out.n) || out.n < 1) throw new Error(`Invalid count: ${v}`); + continue; + } + + if (a.startsWith("-")) { + throw new Error(`Unknown option: ${a}`); + } + + positional.push(a); + } + + if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) { + out.prompt = positional.join(" "); + } + + return out; +} + +async function loadEnvFile(p: string): Promise> { + try { + const content = await readFile(p, "utf8"); + const env: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf("="); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + env[key] = val; + } + return env; + } catch { + return {}; + } +} + +async function loadEnv(): Promise { + const home = homedir(); + const cwd = process.cwd(); + + const homeEnv = await loadEnvFile(path.join(home, ".baoyu-skills", ".env")); + const cwdEnv = await loadEnvFile(path.join(cwd, ".baoyu-skills", ".env")); + + for (const [k, v] of Object.entries(homeEnv)) { + if (!process.env[k]) process.env[k] = v; + } + for (const [k, v] of Object.entries(cwdEnv)) { + if (!process.env[k]) process.env[k] = v; + } +} + +export function extractYamlFrontMatter(content: string): string | null { + const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m); + return match ? match[1] : null; +} + +export function parseSimpleYaml(yaml: string): Partial { + const config: Partial = {}; + const lines = yaml.split("\n"); + let currentKey: string | null = null; + let currentProvider: Provider | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + const indent = line.match(/^\s*/)?.[0].length ?? 0; + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed.includes(":") && !trimmed.startsWith("-")) { + const colonIdx = trimmed.indexOf(":"); + const key = trimmed.slice(0, colonIdx).trim(); + let value = trimmed.slice(colonIdx + 1).trim(); + + if (value === "null" || value === "") { + value = "null"; + } + + if (key === "version") { + config.version = value === "null" ? 1 : parseInt(value, 10); + } else if (key === "default_provider") { + config.default_provider = value === "null" ? null : (value as Provider); + } else if (key === "default_quality") { + config.default_quality = value === "null" ? null : value as "normal" | "2k"; + } else if (key === "default_aspect_ratio") { + const cleaned = value.replace(/['"]/g, ""); + config.default_aspect_ratio = cleaned === "null" ? null : cleaned; + } else if (key === "default_image_size") { + config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K"; + } else if (key === "default_model") { + config.default_model = { + google: null, + openai: null, + openrouter: null, + dashscope: null, + minimax: null, + replicate: null, + jimeng: null, + seedream: null, + azure: null, + }; + currentKey = "default_model"; + currentProvider = null; + } else if (key === "batch") { + config.batch = {}; + currentKey = "batch"; + currentProvider = null; + } else if (currentKey === "batch" && indent >= 2 && key === "max_workers") { + config.batch ??= {}; + config.batch.max_workers = value === "null" ? null : parseInt(value, 10); + } else if (currentKey === "batch" && indent >= 2 && key === "provider_limits") { + config.batch ??= {}; + config.batch.provider_limits ??= {}; + currentKey = "provider_limits"; + currentProvider = null; + } else if ( + currentKey === "provider_limits" && + indent >= 4 && + ( + key === "google" || + key === "openai" || + key === "openrouter" || + key === "dashscope" || + key === "minimax" || + key === "replicate" || + key === "jimeng" || + key === "seedream" || + key === "azure" + ) + ) { + config.batch ??= {}; + config.batch.provider_limits ??= {}; + config.batch.provider_limits[key] ??= {}; + currentProvider = key; + } else if ( + currentKey === "default_model" && + ( + key === "google" || + key === "openai" || + key === "openrouter" || + key === "dashscope" || + key === "minimax" || + key === "replicate" || + key === "jimeng" || + key === "seedream" || + key === "azure" + ) + ) { + const cleaned = value.replace(/['"]/g, ""); + config.default_model![key] = cleaned === "null" ? null : cleaned; + } else if ( + currentKey === "provider_limits" && + currentProvider && + indent >= 6 && + (key === "concurrency" || key === "start_interval_ms") + ) { + config.batch ??= {}; + config.batch.provider_limits ??= {}; + const providerLimit = (config.batch.provider_limits[currentProvider] ??= {}); + if (key === "concurrency") { + providerLimit.concurrency = value === "null" ? null : parseInt(value, 10); + } else { + providerLimit.start_interval_ms = value === "null" ? null : parseInt(value, 10); + } + } + } + } + + return config; +} + +async function loadExtendConfig(): Promise> { + const home = homedir(); + const cwd = process.cwd(); + + const paths = [ + path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"), + path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"), + ]; + + for (const p of paths) { + try { + const content = await readFile(p, "utf8"); + const yaml = extractYamlFrontMatter(content); + if (!yaml) continue; + return parseSimpleYaml(yaml); + } catch { + continue; + } + } + + return {}; +} + +export function mergeConfig(args: CliArgs, extend: Partial): CliArgs { + return { + ...args, + provider: args.provider ?? extend.default_provider ?? null, + quality: args.quality ?? extend.default_quality ?? null, + aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null, + imageSize: args.imageSize ?? extend.default_image_size ?? null, + }; +} + +export function parsePositiveInt(value: string | undefined): number | null { + if (!value) return null; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export function parsePositiveBatchInt(value: unknown): number | null { + if (value === null || value === undefined) return null; + if (typeof value === "number") { + return Number.isInteger(value) && value > 0 ? value : null; + } + if (typeof value === "string") { + return parsePositiveInt(value); + } + return null; +} + +export function getConfiguredMaxWorkers(extendConfig: Partial): number { + const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS); + const configValue = extendConfig.batch?.max_workers ?? null; + return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS); +} + +export function getConfiguredProviderRateLimits( + extendConfig: Partial +): Record { + const configured: Record = { + replicate: { ...DEFAULT_PROVIDER_RATE_LIMITS.replicate }, + google: { ...DEFAULT_PROVIDER_RATE_LIMITS.google }, + openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai }, + openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter }, + dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope }, + minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax }, + jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng }, + seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream }, + azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure }, + }; + + for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure"] as Provider[]) { + const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`; + const extendLimit = extendConfig.batch?.provider_limits?.[provider]; + configured[provider] = { + concurrency: + parsePositiveInt(process.env[`${envPrefix}_CONCURRENCY`]) ?? + extendLimit?.concurrency ?? + configured[provider].concurrency, + startIntervalMs: + parsePositiveInt(process.env[`${envPrefix}_START_INTERVAL_MS`]) ?? + extendLimit?.start_interval_ms ?? + configured[provider].startIntervalMs, + }; + } + + return configured; +} + +async function readPromptFromFiles(files: string[]): Promise { + const parts: string[] = []; + for (const f of files) { + parts.push(await readFile(f, "utf8")); + } + return parts.join("\n\n"); +} + +async function readPromptFromStdin(): Promise { + if (process.stdin.isTTY) return null; + try { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const value = Buffer.concat(chunks).toString("utf8").trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string { + const full = path.resolve(p); + const ext = path.extname(full); + if (ext) return full; + return `${full}${defaultExtension}`; +} + +function inferProviderFromModel(model: string | null): Provider | null { + if (!model) return null; + const normalized = model.trim(); + if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream"; + if (normalized === "image-01" || normalized === "image-01-live") return "minimax"; + return null; +} + +export function detectProvider(args: CliArgs): Provider { + if ( + args.referenceImages.length > 0 && + args.provider && + args.provider !== "google" && + args.provider !== "openai" && + args.provider !== "azure" && + args.provider !== "openrouter" && + args.provider !== "replicate" && + args.provider !== "seedream" && + args.provider !== "minimax" + ) { + 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." + ); + } + + if (args.provider) return args.provider; + + const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY); + const hasAzure = !!(process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_OPENAI_BASE_URL); + const hasOpenai = !!process.env.OPENAI_API_KEY; + const hasOpenrouter = !!process.env.OPENROUTER_API_KEY; + const hasDashscope = !!process.env.DASHSCOPE_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 modelProvider = inferProviderFromModel(args.model); + + if (modelProvider === "seedream") { + if (!hasSeedream) { + throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set."); + } + return "seedream"; + } + + if (modelProvider === "minimax") { + if (!hasMinimax) { + throw new Error("Model looks like a MiniMax image model, but MINIMAX_API_KEY is not set."); + } + return "minimax"; + } + + if (args.referenceImages.length > 0) { + if (hasGoogle) return "google"; + if (hasOpenai) return "openai"; + if (hasAzure) return "azure"; + if (hasOpenrouter) return "openrouter"; + if (hasReplicate) return "replicate"; + if (hasSeedream) return "seedream"; + if (hasMinimax) return "minimax"; + throw new Error( + "Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref." + ); + } + + const available = [ + hasGoogle && "google", + hasOpenai && "openai", + hasAzure && "azure", + hasOpenrouter && "openrouter", + hasDashscope && "dashscope", + hasMinimax && "minimax", + hasReplicate && "replicate", + hasJimeng && "jimeng", + hasSeedream && "seedream", + ].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, or ARK_API_KEY.\n" + + "Create ~/.baoyu-skills/.env or /.baoyu-skills/.env with your keys." + ); +} + +export async function validateReferenceImages(referenceImages: string[]): Promise { + for (const refPath of referenceImages) { + const fullPath = path.resolve(refPath); + try { + await access(fullPath); + } catch { + throw new Error(`Reference image not found: ${fullPath}`); + } + } +} + +export function isRetryableGenerationError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + const nonRetryableMarkers = [ + "Reference image", + "not supported", + "only supported", + "No API key found", + "is required", + "Invalid ", + "Unexpected ", + "API error (400)", + "API error (401)", + "API error (402)", + "API error (403)", + "API error (404)", + "temporarily disabled", + ]; + return !nonRetryableMarkers.some((marker) => msg.includes(marker)); +} + +async function loadProviderModule(provider: Provider): Promise { + if (provider === "google") return (await import("./providers/google")) as ProviderModule; + if (provider === "dashscope") return (await import("./providers/dashscope")) 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; + return (await import("./providers/openai")) as ProviderModule; +} + +async function loadPromptForArgs(args: CliArgs): Promise { + let prompt: string | null = args.prompt; + if (!prompt && args.promptFiles.length > 0) { + prompt = await readPromptFromFiles(args.promptFiles); + } + return prompt; +} + +function getModelForProvider( + provider: Provider, + requestedModel: string | null, + extendConfig: Partial, + providerModule: ProviderModule +): string { + if (requestedModel) return requestedModel; + if (extendConfig.default_model) { + if (provider === "google" && extendConfig.default_model.google) return extendConfig.default_model.google; + if (provider === "openai" && extendConfig.default_model.openai) return extendConfig.default_model.openai; + if (provider === "openrouter" && extendConfig.default_model.openrouter) { + return extendConfig.default_model.openrouter; + } + if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope; + 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; + } + return providerModule.getDefaultModel(); +} + +async function prepareSingleTask(args: CliArgs, extendConfig: Partial): Promise { + if (!args.quality) args.quality = "2k"; + + 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); + + const provider = detectProvider(args); + const providerModule = await loadProviderModule(provider); + const model = getModelForProvider(provider, args.model, extendConfig, providerModule); + providerModule.validateArgs?.(model, args); + const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png"; + + return { + id: "single", + prompt, + args, + provider, + model, + outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension), + providerModule, + }; +} + +export async function loadBatchTasks(batchFilePath: string): Promise { + const resolvedBatchFilePath = path.resolve(batchFilePath); + const content = await readFile(resolvedBatchFilePath, "utf8"); + const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile; + const batchDir = path.dirname(resolvedBatchFilePath); + if (Array.isArray(parsed)) { + return { + tasks: parsed, + jobs: null, + batchDir, + }; + } + if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) { + const jobs = parsePositiveBatchInt(parsed.jobs); + if (parsed.jobs !== undefined && parsed.jobs !== null && jobs === null) { + throw new Error("Invalid batch file. jobs must be a positive integer when provided."); + } + return { + tasks: parsed.tasks, + jobs, + batchDir, + }; + } + throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array."); +} + +export function resolveBatchPath(batchDir: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath); +} + +export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs { + return { + ...baseArgs, + prompt: task.prompt ?? null, + promptFiles: task.promptFiles ? task.promptFiles.map((filePath) => resolveBatchPath(batchDir, filePath)) : [], + imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null, + provider: task.provider ?? baseArgs.provider ?? null, + model: task.model ?? baseArgs.model ?? null, + aspectRatio: task.ar ?? baseArgs.aspectRatio ?? 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)) : [], + n: task.n ?? baseArgs.n, + batchFile: null, + jobs: baseArgs.jobs, + json: baseArgs.json, + help: false, + }; +} + +async function prepareBatchTasks( + args: CliArgs, + extendConfig: Partial +): Promise<{ tasks: PreparedTask[]; jobs: number | null }> { + if (!args.batchFile) throw new Error("--batchfile is required in batch mode"); + const { tasks: taskInputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile); + if (taskInputs.length === 0) throw new Error("Batch file does not contain any tasks."); + + const prepared: PreparedTask[] = []; + for (let i = 0; i < taskInputs.length; i++) { + const task = taskInputs[i]!; + const taskArgs = createTaskArgs(args, task, batchDir); + 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); + + const provider = detectProvider(taskArgs); + const providerModule = await loadProviderModule(provider); + const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule); + providerModule.validateArgs?.(model, taskArgs); + const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png"; + prepared.push({ + id: task.id || `task-${String(i + 1).padStart(2, "0")}`, + prompt, + args: taskArgs, + provider, + model, + outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension), + providerModule, + }); + } + + return { + tasks: prepared, + jobs: args.jobs ?? batchJobs, + }; +} + +async function writeImage(outputPath: string, imageData: Uint8Array): Promise { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, imageData); +} + +async function generatePreparedTask(task: PreparedTask): Promise { + console.error(`Using ${task.provider} / ${task.model} for ${task.id}`); + console.error( + `Switch model: --model | EXTEND.md default_model.${task.provider} | env ${task.provider.toUpperCase()}_IMAGE_MODEL` + ); + + let attempts = 0; + while (attempts < MAX_ATTEMPTS) { + attempts += 1; + try { + const imageData = await task.providerModule.generateImage(task.prompt, task.model, task.args); + await writeImage(task.outputPath, imageData); + return { + id: task.id, + provider: task.provider, + model: task.model, + outputPath: task.outputPath, + success: true, + attempts, + error: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const canRetry = attempts < MAX_ATTEMPTS && isRetryableGenerationError(error); + if (canRetry) { + console.error(`[${task.id}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying...`); + continue; + } + return { + id: task.id, + provider: task.provider, + model: task.model, + outputPath: task.outputPath, + success: false, + attempts, + error: message, + }; + } + } + + return { + id: task.id, + provider: task.provider, + model: task.model, + outputPath: task.outputPath, + success: false, + attempts: MAX_ATTEMPTS, + error: "Unknown failure", + }; +} + +function createProviderGate(providerRateLimits: Record) { + const state = new Map(); + + return async function acquire(provider: Provider): Promise<() => void> { + const limit = providerRateLimits[provider]; + while (true) { + const current = state.get(provider) ?? { active: 0, lastStartedAt: 0 }; + const now = Date.now(); + const enoughCapacity = current.active < limit.concurrency; + const enoughGap = now - current.lastStartedAt >= limit.startIntervalMs; + if (enoughCapacity && enoughGap) { + state.set(provider, { active: current.active + 1, lastStartedAt: now }); + return () => { + const latest = state.get(provider) ?? { active: 1, lastStartedAt: now }; + state.set(provider, { + active: Math.max(0, latest.active - 1), + lastStartedAt: latest.lastStartedAt, + }); + }; + } + await new Promise((resolve) => setTimeout(resolve, POLL_WAIT_MS)); + } + }; +} + +export function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number { + const requested = jobs ?? Math.min(taskCount, maxWorkers); + return Math.max(1, Math.min(requested, taskCount, maxWorkers)); +} + +async function runBatchTasks( + tasks: PreparedTask[], + jobs: number | null, + extendConfig: Partial +): Promise { + if (tasks.length === 1) { + return [await generatePreparedTask(tasks[0]!)]; + } + + const maxWorkers = getConfiguredMaxWorkers(extendConfig); + const providerRateLimits = getConfiguredProviderRateLimits(extendConfig); + 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"] as Provider[]) { + const limit = providerRateLimits[provider]; + console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`); + } + + let nextIndex = 0; + const results: TaskResult[] = new Array(tasks.length); + + const worker = async (): Promise => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= tasks.length) return; + + const task = tasks[currentIndex]!; + const release = await acquireProvider(task.provider); + try { + results[currentIndex] = await generatePreparedTask(task); + } finally { + release(); + } + } + }; + + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +function printBatchSummary(results: TaskResult[]): void { + const successCount = results.filter((result) => result.success).length; + const failureCount = results.length - successCount; + + console.error(""); + console.error("Batch generation summary:"); + console.error(`- Total: ${results.length}`); + console.error(`- Succeeded: ${successCount}`); + console.error(`- Failed: ${failureCount}`); + + if (failureCount > 0) { + console.error("Failure reasons:"); + for (const result of results.filter((item) => !item.success)) { + console.error(`- ${result.id}: ${result.error}`); + } + } +} + +function emitJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +async function runSingleMode(args: CliArgs, extendConfig: Partial): Promise { + const task = await prepareSingleTask(args, extendConfig); + const result = await generatePreparedTask(task); + if (!result.success) { + throw new Error(result.error || "Generation failed"); + } + + if (args.json) { + emitJson({ + savedImage: result.outputPath, + provider: result.provider, + model: result.model, + attempts: result.attempts, + prompt: task.prompt.slice(0, 200), + }); + return; + } + + console.log(result.outputPath); +} + +async function runBatchMode(args: CliArgs, extendConfig: Partial): Promise { + const { tasks, jobs } = await prepareBatchTasks(args, extendConfig); + const results = await runBatchTasks(tasks, jobs, extendConfig); + printBatchSummary(results); + + if (args.json) { + emitJson({ + mode: "batch", + total: results.length, + succeeded: results.filter((item) => item.success).length, + failed: results.filter((item) => !item.success).length, + results, + }); + } + + if (results.some((item) => !item.success)) { + process.exitCode = 1; + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printUsage(); + return; + } + + await loadEnv(); + const extendConfig = await loadExtendConfig(); + const mergedArgs = mergeConfig(args, extendConfig); + if (!mergedArgs.quality) mergedArgs.quality = "2k"; + + if (mergedArgs.batchFile) { + await runBatchMode(mergedArgs, extendConfig); + return; + } + + await runSingleMode(mergedArgs, extendConfig); +} + +function isDirectExecution(metaUrl: string): boolean { + const entryPath = process.argv[1]; + if (!entryPath) return false; + + try { + return path.resolve(entryPath) === fileURLToPath(metaUrl); + } catch { + return false; + } +} + +if (isDirectExecution(import.meta.url)) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); + }); +} diff --git a/skills/baoyu-image-gen/scripts/providers/azure.test.ts b/skills/baoyu-image-gen/scripts/providers/azure.test.ts new file mode 100644 index 0000000..298ac64 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/azure.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + generateImage, + getDefaultModel, + parseAzureBaseURL, + validateArgs, +} from "./azure.ts"; + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + 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 { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +async function makeTempDir(prefix: string): Promise { + 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; + assert.equal(body.quality, "medium"); + assert.equal(body.size, "1024x1024"); +}); + +test("Azure image edits include quality in multipart requests", async (t) => { + const root = await makeTempDir("baoyu-image-gen-azure-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const pngPath = path.join(root, "ref.png"); + const jpgPath = path.join(root, "ref.jpg"); + await fs.writeFile(pngPath, "png-bytes"); + await fs.writeFile(jpgPath, "jpg-bytes"); + + useEnv(t, { + AZURE_OPENAI_API_KEY: "azure-key", + AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com", + AZURE_API_VERSION: "2025-04-01-preview", + AZURE_OPENAI_DEPLOYMENT: null, + AZURE_OPENAI_IMAGE_MODEL: null, + }); + + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const calls: Array<{ url: string; form: FormData }> = []; + globalThis.fetch = async (input, init) => { + calls.push({ + url: String(input), + form: init?.body as FormData, + }); + return Response.json({ + data: [{ b64_json: Buffer.from("edited-image").toString("base64") }], + }); + }; + + const bytes = await generateImage( + "Add warm lighting", + "edit-deploy", + makeArgs({ + quality: "2k", + referenceImages: [pngPath, jpgPath], + }), + ); + + assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image"); + assert.equal( + calls[0]?.url, + "https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview", + ); + assert.equal(calls[0]?.form.get("quality"), "high"); + assert.equal(calls[0]?.form.get("size"), "1024x1024"); + assert.equal(calls[0]?.form.getAll("image[]").length, 2); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/azure.ts b/skills/baoyu-image-gen/scripts/providers/azure.ts new file mode 100644 index 0000000..ab39917 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/azure.ts @@ -0,0 +1,192 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { CliArgs } from "../types"; +import { getOpenAISize, extractImageFromResponse } from "./openai.ts"; + +type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> }; +type AzureEndpoint = { + resourceBaseURL: string; + deployment: string | null; +}; + +const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview"; +const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]); + +export function parseAzureBaseURL(url: string): AzureEndpoint { + const parsed = new URL(url); + const trimmedPath = parsed.pathname.replace(/\/+$/, ""); + const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/); + + if (deploymentMatch) { + parsed.pathname = `${deploymentMatch[1] || ""}/openai`; + return { + resourceBaseURL: parsed.toString().replace(/\/+$/, ""), + deployment: decodeURIComponent(deploymentMatch[2]!), + }; + } + + parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`; + return { + resourceBaseURL: parsed.toString().replace(/\/+$/, ""), + deployment: null, + }; +} + +export function getDefaultModel(): string { + const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim(); + if (explicitDeployment) return explicitDeployment; + + const baseURL = process.env.AZURE_OPENAI_BASE_URL; + if (baseURL) { + try { + const { deployment } = parseAzureBaseURL(baseURL); + if (deployment) return deployment; + } catch { + // Ignore invalid URLs here so the required-env check can raise the user-facing error later. + } + } + + return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5"; +} + +function getEndpoint(): AzureEndpoint { + const url = process.env.AZURE_OPENAI_BASE_URL; + if (!url) { + throw new Error( + "AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment" + ); + } + return parseAzureBaseURL(url); +} + +function getApiKey(): string { + const key = process.env.AZURE_OPENAI_API_KEY; + if (!key) { + throw new Error( + "AZURE_OPENAI_API_KEY is required. Get it from Azure Portal → your OpenAI resource → Keys and Endpoint." + ); + } + return key; +} + +function getApiVersion(): string { + return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION; +} + +function getDeployment(model: string): string { + const deployment = model.trim(); + if (!deployment) { + throw new Error( + "Azure deployment name is required. Use --model , 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 { + 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 { + 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 { + const body: Record = { + 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 { + 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); +} diff --git a/skills/baoyu-image-gen/scripts/providers/dashscope.test.ts b/skills/baoyu-image-gen/scripts/providers/dashscope.test.ts new file mode 100644 index 0000000..ab6100a --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/dashscope.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; + +import { + getDefaultModel, + getModelFamily, + getQwen2SizeFromAspectRatio, + getSizeFromAspectRatio, + normalizeSize, + parseAspectRatio, + parseSize, + resolveSizeForModel, +} from "./dashscope.ts"; + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + t.after(() => { + for (const [key, value] of previous.entries()) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => { + useEnv(t, { DASHSCOPE_IMAGE_MODEL: null }); + assert.equal(getDefaultModel(), "qwen-image-2.0-pro"); + + process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max"; + assert.equal(getDefaultModel(), "qwen-image-max"); +}); + +test("DashScope aspect-ratio parsing accepts numeric ratios only", () => { + assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 }); + assert.equal(parseAspectRatio("square"), null); + assert.equal(parseAspectRatio("-1:2"), null); +}); + +test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => { + assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2"); + assert.equal(getModelFamily("qwen-image"), "qwenFixed"); + assert.equal(getModelFamily("z-image-turbo"), "legacy"); + assert.equal(getModelFamily("wanx-v1"), "legacy"); +}); + +test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => { + assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024"); + assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720"); + assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152"); + assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536"); +}); + +test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => { + assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024"); + assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536"); + assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720"); + assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872"); +}); + +test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => { + const size = getQwen2SizeFromAspectRatio("5:2", "normal"); + const parsed = parseSize(size); + assert.ok(parsed); + assert.ok(parsed.width * parsed.height >= 512 * 512); + assert.ok(parsed.width * parsed.height <= 2048 * 2048); + assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08); +}); + +test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => { + assert.equal( + resolveSizeForModel("qwen-image-2.0-pro", { + size: "2048x872", + aspectRatio: null, + quality: "2k", + }), + "2048*872", + ); + + assert.throws( + () => + resolveSizeForModel("qwen-image-2.0-pro", { + size: "4096x4096", + aspectRatio: null, + quality: "2k", + }), + /total pixels between/, + ); +}); + +test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => { + assert.equal( + resolveSizeForModel("qwen-image-max", { + size: null, + aspectRatio: "1:1", + quality: "2k", + }), + "1328*1328", + ); + + assert.equal( + resolveSizeForModel("qwen-image", { + size: "1664x928", + aspectRatio: "9:16", + quality: "normal", + }), + "1664*928", + ); + + assert.throws( + () => + resolveSizeForModel("qwen-image-max", { + size: null, + aspectRatio: "21:9", + quality: "2k", + }), + /supports only fixed ratios/, + ); + + assert.throws( + () => + resolveSizeForModel("qwen-image-plus", { + size: "1024x1024", + aspectRatio: null, + quality: "2k", + }), + /support only these sizes/, + ); +}); + +test("DashScope size normalization converts WxH into provider format", () => { + assert.equal(normalizeSize("1024x1024"), "1024*1024"); + assert.equal(normalizeSize("2048*1152"), "2048*1152"); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/dashscope.ts b/skills/baoyu-image-gen/scripts/providers/dashscope.ts new file mode 100644 index 0000000..60228c1 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/dashscope.ts @@ -0,0 +1,463 @@ +import type { CliArgs, Quality } from "../types"; + +type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy"; + +type DashScopeModelSpec = { + family: DashScopeModelFamily; + defaultSize: string; +}; + +const DEFAULT_MODEL = "qwen-image-2.0-pro"; +const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512; +const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048; +const SIZE_STEP = 16; +const QWEN_NEGATIVE_PROMPT = + "低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲"; + +const QWEN_2_TARGET_PIXELS: Record = { + normal: 1024 * 1024, + "2k": 1536 * 1536, +}; + +const QWEN_2_RECOMMENDED: Record> = { + "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 = { + "16:9": "1664*928", + "4:3": "1472*1104", + "1:1": "1328*1328", + "3:4": "1104*1472", + "9:16": "928*1664", +}; + +const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO); + +const LEGACY_STANDARD_SIZES: [number, number][] = [ + [1024, 1024], + [1280, 720], + [720, 1280], + [1024, 768], + [768, 1024], + [1536, 1024], + [1024, 1536], + [1536, 864], + [864, 1536], +]; + +const LEGACY_STANDARD_SIZES_2K: [number, number][] = [ + [1536, 1536], + [2048, 1152], + [1152, 2048], + [1536, 1024], + [1024, 1536], + [1536, 864], + [864, 1536], + [2048, 2048], +]; + +const QWEN_2_SPEC: DashScopeModelSpec = { + family: "qwen2", + defaultSize: "1024*1024", +}; + +const QWEN_FIXED_SPEC: DashScopeModelSpec = { + family: "qwenFixed", + defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"], +}; + +const LEGACY_SPEC: DashScopeModelSpec = { + family: "legacy", + defaultSize: "1536*1536", +}; + +const MODEL_SPEC_ALIASES: Record = { + "qwen-image-2.0-pro": QWEN_2_SPEC, + "qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC, + "qwen-image-2.0": QWEN_2_SPEC, + "qwen-image-2.0-2026-03-03": QWEN_2_SPEC, + "qwen-image-max": QWEN_FIXED_SPEC, + "qwen-image-max-2025-12-30": QWEN_FIXED_SPEC, + "qwen-image-plus": QWEN_FIXED_SPEC, + "qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC, + "qwen-image": QWEN_FIXED_SPEC, +}; + +export function getDefaultModel(): string { + return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL; +} + +function getApiKey(): string | null { + return process.env.DASHSCOPE_API_KEY || null; +} + +function getBaseUrl(): string { + const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com"; + return base.replace(/\/+$/g, ""); +} + +function getModelSpec(model: string): DashScopeModelSpec { + return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC; +} + +export function getModelFamily(model: string): DashScopeModelFamily { + return getModelSpec(model).family; +} + +function normalizeQuality(quality: CliArgs["quality"]): Quality { + return quality === "normal" ? "normal" : "2k"; +} + +export function parseAspectRatio(ar: string): { width: number; height: number } | null { + const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/); + if (!match) return null; + const w = parseFloat(match[1]!); + const h = parseFloat(match[2]!); + if (w <= 0 || h <= 0) return null; + return { width: w, height: h }; +} + +export function normalizeSize(size: string): string { + return size.replace("x", "*"); +} + +export function parseSize(size: string): { width: number; height: number } | null { + const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/); + if (!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function formatSize(width: number, height: number): string { + return `${width}*${height}`; +} + +function getRatioValue(ar: string): number | null { + const parsed = parseAspectRatio(ar); + if (!parsed) return null; + return parsed.width / parsed.height; +} + +function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null { + const targetRatio = getRatioValue(ar); + if (targetRatio == null) return null; + + let bestKey: string | null = null; + let bestDiff = Infinity; + + for (const candidate of candidates) { + const candidateRatio = getRatioValue(candidate); + if (candidateRatio == null) continue; + const diff = Math.abs(candidateRatio - targetRatio); + if (diff < bestDiff) { + bestDiff = diff; + bestKey = candidate; + } + } + + return bestDiff <= tolerance ? bestKey : null; +} + +function roundToStep(value: number): number { + return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP); +} + +function fitToPixelBudget( + width: number, + height: number, + minPixels: number, + maxPixels: number, +): { width: number; height: number } { + let nextWidth = width; + let nextHeight = height; + let pixels = nextWidth * nextHeight; + + if (pixels > maxPixels) { + const scale = Math.sqrt(maxPixels / pixels); + nextWidth *= scale; + nextHeight *= scale; + } else if (pixels < minPixels) { + const scale = Math.sqrt(minPixels / pixels); + nextWidth *= scale; + nextHeight *= scale; + } + + let roundedWidth = roundToStep(nextWidth); + let roundedHeight = roundToStep(nextHeight); + pixels = roundedWidth * roundedHeight; + + while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) { + if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) { + roundedWidth -= SIZE_STEP; + } else if (roundedHeight > SIZE_STEP) { + roundedHeight -= SIZE_STEP; + } else { + break; + } + pixels = roundedWidth * roundedHeight; + } + + while (pixels < minPixels) { + if (roundedWidth <= roundedHeight) { + roundedWidth += SIZE_STEP; + } else { + roundedHeight += SIZE_STEP; + } + pixels = roundedWidth * roundedHeight; + } + + return { width: roundedWidth, height: roundedHeight }; +} + +export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string { + const normalizedQuality = normalizeQuality(quality); + const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES; + const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024"; + + if (!ar) return defaultSize; + + const parsed = parseAspectRatio(ar); + if (!parsed) return defaultSize; + + const targetRatio = parsed.width / parsed.height; + let best = defaultSize; + let bestDiff = Infinity; + + for (const [width, height] of sizes) { + const diff = Math.abs(width / height - targetRatio); + if (diff < bestDiff) { + bestDiff = diff; + best = formatSize(width, height); + } + } + + return best; +} + +export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string { + const normalizedQuality = normalizeQuality(quality); + + if (!ar) { + return QWEN_2_RECOMMENDED["1:1"][normalizedQuality]; + } + + const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED)); + if (recommendedRatio) { + return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality]; + } + + const parsed = parseAspectRatio(ar); + if (!parsed) { + return QWEN_2_RECOMMENDED["1:1"][normalizedQuality]; + } + + const targetRatio = parsed.width / parsed.height; + const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality]; + const rawWidth = Math.sqrt(targetPixels * targetRatio); + const rawHeight = Math.sqrt(targetPixels / targetRatio); + const fitted = fitToPixelBudget( + rawWidth, + rawHeight, + MIN_QWEN_2_TOTAL_PIXELS, + MAX_QWEN_2_TOTAL_PIXELS, + ); + + return formatSize(fitted.width, fitted.height); +} + +function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string { + if (quality === "normal") { + console.warn( + "DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution." + ); + } + + if (!ar) return QWEN_FIXED_SPEC.defaultSize; + + const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO)); + if (!ratioKey) { + throw new Error( + `DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` + + `For custom ratios like "${ar}", use --model qwen-image-2.0-pro.` + ); + } + + return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!; +} + +function validateSizeFormat(size: string): { width: number; height: number } { + const parsed = parseSize(size); + if (!parsed) { + throw new Error(`Invalid DashScope size "${size}". Expected x or *.`); + } + 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, +): string { + const spec = getModelSpec(model); + + if (args.size) { + if (spec.family === "qwen2") return validateQwen2Size(args.size); + if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size); + validateSizeFormat(args.size); + return normalizeSize(args.size); + } + + if (spec.family === "qwen2") { + return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality); + } + + if (spec.family === "qwenFixed") { + return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality); + } + + return getSizeFromAspectRatio(args.aspectRatio, args.quality); +} + +function buildParameters( + family: DashScopeModelFamily, + size: string, +): Record { + const parameters: Record = { + 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 { + 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 { + const apiKey = getApiKey(); + if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required"); + + if (args.referenceImages.length > 0) { + throw new Error( + "Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model." + ); + } + + const spec = getModelSpec(model); + const size = resolveSizeForModel(model, args); + const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`; + + const body = { + model, + input: { + messages: [ + { + role: "user", + content: [{ text: prompt }], + }, + ], + }, + parameters: buildParameters(spec.family, size), + }; + + console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size }); + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`DashScope API error (${res.status}): ${err}`); + } + + const result = await res.json() as DashScopeResponse; + return extractImageFromResponse(result); +} diff --git a/skills/baoyu-image-gen/scripts/providers/google.test.ts b/skills/baoyu-image-gen/scripts/providers/google.test.ts new file mode 100644 index 0000000..aec3372 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/google.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + addAspectRatioToPrompt, + buildGoogleUrl, + buildPromptWithAspect, + extractInlineImageData, + extractPredictedImageData, + getGoogleImageSize, + isGoogleImagen, + isGoogleMultimodal, + normalizeGoogleModelId, +} from "./google.ts"; + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + 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 { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("Google provider helpers normalize model IDs and select image size defaults", () => { + assert.equal( + normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"), + "gemini-3.1-flash-image-preview", + ); + assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true); + assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true); + assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K"); + assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K"); +}); + +test("Google URL builder appends v1beta when the base URL does not already include it", (t) => { + useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" }); + assert.equal( + buildGoogleUrl("models/demo:generateContent"), + "https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent", + ); +}); + +test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => { + useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" }); + assert.equal( + buildGoogleUrl("/models/demo:predict"), + "https://example.com/custom/v1beta/models/demo:predict", + ); + + assert.equal( + addAspectRatioToPrompt("A city skyline", "16:9"), + "A city skyline Aspect ratio: 16:9.", + ); + assert.equal( + buildPromptWithAspect("A city skyline", "16:9", "2k"), + "A city skyline Aspect ratio: 16:9. High resolution 2048px.", + ); +}); + +test("Google response extractors find inline and predicted image payloads", () => { + assert.equal( + extractInlineImageData({ + candidates: [ + { + content: { + parts: [{ inlineData: { data: "inline-base64" } }], + }, + }, + ], + }), + "inline-base64", + ); + + assert.equal( + extractPredictedImageData({ + predictions: [{ image: { imageBytes: "predicted-base64" } }], + }), + "predicted-base64", + ); + + assert.equal( + extractPredictedImageData({ + generatedImages: [{ bytesBase64Encoded: "generated-base64" }], + }), + "generated-base64", + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/google.ts b/skills/baoyu-image-gen/scripts/providers/google.ts new file mode 100644 index 0000000..09985d4 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/google.ts @@ -0,0 +1,349 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import type { CliArgs } from "../types"; + +const GOOGLE_MULTIMODAL_MODELS = [ + "gemini-3-pro-image-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-image-preview", +]; +const GOOGLE_IMAGEN_MODELS = [ + "imagen-3.0-generate-002", + "imagen-3.0-generate-001", +]; + +export function getDefaultModel(): string { + return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview"; +} + +export function normalizeGoogleModelId(model: string): string { + return model.startsWith("models/") ? model.slice("models/".length) : model; +} + +export function isGoogleMultimodal(model: string): boolean { + const normalized = normalizeGoogleModelId(model); + return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m)); +} + +export function isGoogleImagen(model: string): boolean { + const normalized = normalizeGoogleModelId(model); + return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m)); +} + +function getGoogleApiKey(): string | null { + return process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null; +} + +export function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" { + if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K"; + return args.quality === "2k" ? "2K" : "1K"; +} + +function getGoogleBaseUrl(): string { + const base = + process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com"; + return base.replace(/\/+$/g, ""); +} + +export function buildGoogleUrl(pathname: string): string { + const base = getGoogleBaseUrl(); + const cleanedPath = pathname.replace(/^\/+/g, ""); + if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`; + return `${base}/v1beta/${cleanedPath}`; +} + +function toModelPath(model: string): string { + const modelId = normalizeGoogleModelId(model); + return `models/${modelId}`; +} + +function getHttpProxy(): string | null { + return ( + process.env.https_proxy || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.HTTP_PROXY || + process.env.ALL_PROXY || + null + ); +} + +async function postGoogleJsonViaCurl( + url: string, + apiKey: string, + body: unknown, +): Promise { + 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( + url: string, + apiKey: string, + body: unknown, +): Promise { + 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(pathname: string, body: unknown): Promise { + 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(url, apiKey, body); + } + + return postGoogleJsonViaFetch(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; + generatedImages?: Array; +}): 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 { + 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 { + 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 = { + 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; + generatedImages?: Array; + }>(`${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 { + 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); +} diff --git a/skills/baoyu-image-gen/scripts/providers/jimeng.test.ts b/skills/baoyu-image-gen/scripts/providers/jimeng.test.ts new file mode 100644 index 0000000..ed38fb9 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/jimeng.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { generateImage } from "./jimeng.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + 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; + 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); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/jimeng.ts b/skills/baoyu-image-gen/scripts/providers/jimeng.ts new file mode 100644 index 0000000..4ea1938 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/jimeng.ts @@ -0,0 +1,467 @@ +import type { CliArgs } from "../types"; +import * as crypto from "node:crypto"; + +type JimengSizePreset = "normal" | "2k" | "4k"; + +export function getDefaultModel(): string { + return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40"; +} + +function getAccessKey(): string | null { + return process.env.JIMENG_ACCESS_KEY_ID || null; +} + +function getSecretKey(): string | null { + return process.env.JIMENG_SECRET_ACCESS_KEY || null; +} + +function getRegion(): string { + return process.env.JIMENG_REGION || "cn-north-1"; +} + +function getBaseUrl(): string { + return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com"; +} + +function resolveEndpoint(query: Record): { + 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, + headers: Record, + 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> = { + 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 { + // 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 { + 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 { + 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; +} diff --git a/skills/baoyu-image-gen/scripts/providers/minimax.test.ts b/skills/baoyu-image-gen/scripts/providers/minimax.test.ts new file mode 100644 index 0000000..c334634 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/minimax.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildMinimaxUrl, + buildRequestBody, + buildSubjectReference, + extractImageFromResponse, + parsePixelSize, + validateArgs, +} from "./minimax.ts"; + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + 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 { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("MiniMax URL builder normalizes /v1 suffixes", (t) => { + useEnv(t, { MINIMAX_BASE_URL: "https://api.minimax.io" }); + assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation"); + + process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/"; + assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation"); +}); + +test("MiniMax size parsing and validation follow documented constraints", () => { + assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 }); + assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 }); + assert.equal(parsePixelSize("wide"), null); + + validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 })); + + assert.throws( + () => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })), + /only supported with model image-01/, + ); + assert.throws( + () => validateArgs("image-01", makeArgs({ size: "1537x1024" })), + /divisible by 8/, + ); + assert.throws( + () => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })), + /aspect_ratio must be one of/, + ); + assert.throws( + () => validateArgs("image-01", makeArgs({ n: 10 })), + /at most 9 images/, + ); +}); + +test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const refPath = path.join(dir, "portrait.png"); + await fs.writeFile(refPath, Buffer.from("portrait")); + + const ratioBody = await buildRequestBody( + "A portrait by the window", + "image-01", + makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }), + ); + assert.equal(ratioBody.aspect_ratio, "16:9"); + assert.equal(ratioBody.n, 2); + assert.equal(ratioBody.response_format, "base64"); + assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/); + + const sizeBody = await buildRequestBody( + "A portrait by the window", + "image-01", + makeArgs({ size: "1536x1024" }), + ); + assert.equal(sizeBody.width, 1536); + assert.equal(sizeBody.height, 1024); + assert.equal(sizeBody.aspect_ratio, undefined); +}); + +test("MiniMax subject references require supported file types", async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const good = path.join(dir, "portrait.jpg"); + const bad = path.join(dir, "portrait.webp"); + await fs.writeFile(good, Buffer.from("portrait")); + await fs.writeFile(bad, Buffer.from("portrait")); + + const subjectReference = await buildSubjectReference([good]); + assert.equal(subjectReference?.[0]?.type, "character"); + + await assert.rejects( + () => buildSubjectReference([bad]), + /only supports JPG, JPEG, or PNG/, + ); +}); + +test("MiniMax response extraction supports base64 and URL payloads", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const fromBase64 = await extractImageFromResponse({ + data: { + image_base64: [Buffer.from("hello").toString("base64")], + }, + }); + assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello"); + + globalThis.fetch = async () => + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }); + + const fromUrl = await extractImageFromResponse({ + data: { + image_urls: ["https://example.com/output.jpg"], + }, + }); + assert.deepEqual([...fromUrl], [1, 2, 3]); + + await assert.rejects( + () => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }), + /blocked/, + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/minimax.ts b/skills/baoyu-image-gen/scripts/providers/minimax.ts new file mode 100644 index 0000000..67368b8 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/minimax.ts @@ -0,0 +1,220 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import type { CliArgs } from "../types"; + +const DEFAULT_MODEL = "image-01"; +const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024; +const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]); + +type MinimaxSubjectReference = { + type: "character"; + image_file: string; +}; + +type MinimaxRequestBody = { + model: string; + prompt: string; + response_format: "base64"; + aspect_ratio?: string; + width?: number; + height?: number; + n?: number; + subject_reference?: MinimaxSubjectReference[]; +}; + +type MinimaxResponse = { + id?: string; + data?: { + image_urls?: string[]; + image_base64?: string[]; + }; + base_resp?: { + status_code?: number; + status_msg?: string; + }; +}; + +export function getDefaultModel(): string { + return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL; +} + +function getApiKey(): string | null { + return process.env.MINIMAX_API_KEY || null; +} + +export function buildMinimaxUrl(): string { + const base = (process.env.MINIMAX_BASE_URL || "https://api.minimax.io").replace(/\/+$/g, ""); + return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`; +} + +function getMimeType(filename: string): "image/jpeg" | "image/png" { + const ext = path.extname(filename).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".png") return "image/png"; + throw new Error( + `MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}` + ); +} + +export function parsePixelSize(size: string): { width: number; height: number } | null { + const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/); + if (!match) return null; + + const width = parseInt(match[1]!, 10); + const height = parseInt(match[2]!, 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + return { width, height }; +} + +function validatePixelSize(width: number, height: number): void { + if (width < 512 || width > 2048 || height < 512 || height > 2048) { + throw new Error("MiniMax custom size must keep width and height between 512 and 2048."); + } + if (width % 8 !== 0 || height % 8 !== 0) { + throw new Error("MiniMax custom size requires width and height divisible by 8."); + } +} + +export function validateArgs(model: string, args: CliArgs): void { + if (args.n > 9) { + throw new Error("MiniMax supports at most 9 images per request."); + } + + if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) { + throw new Error( + `MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.` + ); + } + + if (args.size && !args.aspectRatio) { + if (model !== "image-01") { + throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead."); + } + const parsed = parsePixelSize(args.size); + if (!parsed) { + throw new Error("MiniMax --size must be in WxH format, for example 1536x1024."); + } + validatePixelSize(parsed.width, parsed.height); + } +} + +export async function buildSubjectReference( + referenceImages: string[], +): Promise { + 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 { + 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 { + 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 { + 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 { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimax.io/"); + } + + const body = await buildRequestBody(prompt, model, args); + const response = await fetch(buildMinimaxUrl(), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`MiniMax API error (${response.status}): ${err}`); + } + + const result = (await response.json()) as MinimaxResponse; + return extractImageFromResponse(result); +} diff --git a/skills/baoyu-image-gen/scripts/providers/openai.test.ts b/skills/baoyu-image-gen/scripts/providers/openai.test.ts new file mode 100644 index 0000000..c4dcd79 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/openai.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + extractImageFromResponse, + getMimeType, + getOpenAISize, + parseAspectRatio, +} from "./openai.ts"; + +test("OpenAI aspect-ratio parsing and size selection match model families", () => { + assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 }); + assert.equal(parseAspectRatio("wide"), null); + assert.equal(parseAspectRatio("0:1"), null); + + assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024"); + assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792"); + assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024"); + assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024"); + assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024"); +}); + +test("OpenAI mime-type detection covers supported reference image extensions", () => { + assert.equal(getMimeType("frame.png"), "image/png"); + assert.equal(getMimeType("frame.jpg"), "image/jpeg"); + assert.equal(getMimeType("frame.webp"), "image/webp"); + assert.equal(getMimeType("frame.gif"), "image/gif"); +}); + +test("OpenAI response extraction supports base64 and URL download flows", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const fromBase64 = await extractImageFromResponse({ + data: [{ b64_json: Buffer.from("hello").toString("base64") }], + }); + assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello"); + + globalThis.fetch = async () => + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + }); + + const fromUrl = await extractImageFromResponse({ + data: [{ url: "https://example.com/image.png" }], + }); + assert.deepEqual([...fromUrl], [1, 2, 3]); + + await assert.rejects( + () => extractImageFromResponse({ data: [{}] }), + /No image in response/, + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/openai.ts b/skills/baoyu-image-gen/scripts/providers/openai.ts new file mode 100644 index 0000000..875631d --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/openai.ts @@ -0,0 +1,227 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { CliArgs } from "../types"; + +export function getDefaultModel(): string { + return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5"; +} + +type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> }; + +export function parseAspectRatio(ar: string): { width: number; height: number } | null { + const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/); + if (!match) return null; + const w = parseFloat(match[1]!); + const h = parseFloat(match[2]!); + if (w <= 0 || h <= 0) return null; + return { width: w, height: h }; +} + +type SizeMapping = { + square: string; + landscape: string; + portrait: string; +}; + +export function getOpenAISize( + model: string, + ar: string | null, + quality: CliArgs["quality"] +): string { + const isDalle3 = model.includes("dall-e-3"); + const isDalle2 = model.includes("dall-e-2"); + + if (isDalle2) { + return "1024x1024"; + } + + const sizes: SizeMapping = isDalle3 + ? { + square: "1024x1024", + landscape: "1792x1024", + portrait: "1024x1792", + } + : { + square: "1024x1024", + landscape: "1536x1024", + portrait: "1024x1536", + }; + + if (!ar) return sizes.square; + + const parsed = parseAspectRatio(ar); + if (!parsed) return sizes.square; + + const ratio = parsed.width / parsed.height; + + if (Math.abs(ratio - 1) < 0.1) return sizes.square; + if (ratio > 1.5) return sizes.landscape; + if (ratio < 0.67) return sizes.portrait; + return sizes.square; +} + +export async function generateImage( + prompt: string, + model: string, + args: CliArgs +): Promise { + const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"; + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error( + "OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script." + ); + } + + if (process.env.OPENAI_IMAGE_USE_CHAT === "true") { + return generateWithChatCompletions(baseURL, apiKey, prompt, model); + } + + const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality); + + if (args.referenceImages.length > 0) { + if (model.includes("dall-e-2") || model.includes("dall-e-3")) { + throw new Error( + "Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)." + ); + } + return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality); + } + + return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality); +} + +async function generateWithChatCompletions( + baseURL: string, + apiKey: string, + prompt: string, + model: string +): Promise { + const res = await fetch(`${baseURL}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`OpenAI API error: ${err}`); + } + + const result = (await res.json()) as { choices: Array<{ message: { content: string } }> }; + const content = result.choices[0]?.message?.content ?? ""; + + const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/); + if (match) { + return Uint8Array.from(Buffer.from(match[1]!, "base64")); + } + + throw new Error("No image found in chat completions response"); +} + +async function generateWithOpenAIGenerations( + baseURL: string, + apiKey: string, + prompt: string, + model: string, + size: string, + quality: CliArgs["quality"] +): Promise { + const body: Record = { model, prompt, size }; + + if (model.includes("dall-e-3")) { + body.quality = quality === "2k" ? "hd" : "standard"; + } + + const res = await fetch(`${baseURL}/images/generations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`OpenAI API error: ${err}`); + } + + const result = (await res.json()) as OpenAIImageResponse; + return extractImageFromResponse(result); +} + +async function generateWithOpenAIEdits( + baseURL: string, + apiKey: string, + prompt: string, + model: string, + size: string, + referenceImages: string[], + quality: CliArgs["quality"] +): Promise { + const form = new FormData(); + form.append("model", model); + form.append("prompt", prompt); + form.append("size", size); + + if (model.includes("gpt-image")) { + form.append("quality", quality === "2k" ? "high" : "medium"); + } + + for (const refPath of referenceImages) { + const bytes = await readFile(refPath); + const filename = path.basename(refPath); + const mimeType = getMimeType(filename); + const blob = new Blob([bytes], { type: mimeType }); + form.append("image[]", blob, filename); + } + + const res = await fetch(`${baseURL}/images/edits`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + body: form, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`OpenAI edits API error: ${err}`); + } + + const result = (await res.json()) as OpenAIImageResponse; + return extractImageFromResponse(result); +} + +export function getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + if (ext === ".gif") return "image/gif"; + return "image/png"; +} + +export async function extractImageFromResponse(result: OpenAIImageResponse): Promise { + 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"); +} diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts new file mode 100644 index 0000000..415122e --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildContent, + buildRequestBody, + extractImageFromResponse, + getAspectRatio, + getImageSize, + validateArgs, +} from "./openrouter.ts"; + +const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview"; +const GEMINI_25_MODEL = "google/gemini-2.5-flash-image"; +const GPT_5_IMAGE_MODEL = "openai/gpt-5-image"; +const OPENROUTER_AUTO_MODEL = "openrouter/auto"; +const FLUX_MODEL = "black-forest-labs/flux.2-pro"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("OpenRouter request body uses image_config and string content for text-only prompts", () => { + const args = makeArgs({ aspectRatio: "16:9", quality: "2k" }); + const body = buildRequestBody("hello", GEMINI_MODEL, args, []); + + assert.deepEqual(body.image_config, { + image_size: "2K", + aspect_ratio: "16:9", + }); + assert.deepEqual(body.provider, { + require_parameters: true, + }); + assert.deepEqual(body.modalities, ["image", "text"]); + assert.equal(body.stream, false); + assert.equal(body.messages[0].content, "hello"); +}); + +test("OpenRouter request body keeps text+image modalities for current text+image models", () => { + for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) { + const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []); + + assert.deepEqual(body.image_config, { + image_size: "2K", + }); + assert.deepEqual(body.provider, { + require_parameters: true, + }); + assert.deepEqual(body.modalities, ["image", "text"]); + assert.equal(body.messages[0].content, "hello"); + } +}); + +test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => { + const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []); + + assert.deepEqual(body.image_config, { + image_size: "2K", + }); + assert.deepEqual(body.provider, { + require_parameters: true, + }); + assert.deepEqual(body.modalities, ["image"]); + assert.equal(body.stream, false); + assert.equal(body.messages[0].content, "hello"); +}); + +test("OpenRouter helper omits image_config when no size or quality is passed", () => { + const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []); + + assert.equal(body.image_config, undefined); + assert.equal(body.provider, undefined); + assert.deepEqual(body.modalities, ["image"]); + assert.equal(body.stream, false); + assert.equal(body.messages[0].content, "hello"); +}); + +test("OpenRouter request body keeps multimodal array content when references are provided", () => { + const content = buildContent("hello", ["data:image/png;base64,abc"]); + assert.ok(Array.isArray(content)); + assert.deepEqual(content[0], { type: "text", text: "hello" }); + assert.deepEqual(content[1], { + type: "image_url", + image_url: { url: "data:image/png;base64,abc" }, + }); +}); + +test("OpenRouter size and aspect helpers infer supported values", () => { + assert.equal(getImageSize(makeArgs()), null); + assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K"); + assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K"); + assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9"); + assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4"); + assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9"); + assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null); +}); + +test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => { + assert.doesNotThrow(() => + validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })), + ); + assert.doesNotThrow(() => + validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), + ); + assert.throws( + () => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })), + /does not support aspect ratio 1:4/, + ); + assert.throws( + () => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })), + /does not support aspect ratio 1:4/, + ); + assert.throws( + () => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })), + /does not support size 2048x1024 \(aspect ratio 2:1\)/, + ); +}); + +test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => { + const bytes = await extractImageFromResponse({ + choices: [ + { + message: { + images: [ + { + image_url: { + url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`, + }, + }, + ], + }, + }, + ], + }); + assert.equal(Buffer.from(bytes).toString("utf8"), "hello"); + + await assert.rejects( + () => + extractImageFromResponse({ + choices: [ + { + finish_reason: "error", + native_finish_reason: "MALFORMED_FUNCTION_CALL", + message: { content: null }, + }, + ], + }), + /finish_reason=MALFORMED_FUNCTION_CALL/, + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.ts new file mode 100644 index 0000000..79bb57f --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.ts @@ -0,0 +1,369 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { CliArgs } from "../types"; + +const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"; +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]; +const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"]; + +type OpenRouterImageEntry = { + image_url?: string | { url?: string | null } | null; + imageUrl?: string | { url?: string | null } | null; +}; + +type OpenRouterMessagePart = { + type?: string; + text?: string; + image_url?: string | { url?: string | null } | null; + imageUrl?: string | { url?: string | null } | null; +}; + +type OpenRouterResponse = { + choices?: Array<{ + finish_reason?: string | null; + native_finish_reason?: string | null; + message?: { + images?: OpenRouterImageEntry[]; + content?: string | OpenRouterMessagePart[] | null; + }; + }>; +}; + +export function getDefaultModel(): string { + return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL; +} + +function normalizeModelId(model: string): string { + return model.trim().toLowerCase().split(":")[0]!; +} + +function isTextAndImageModel(model: string): boolean { + const normalized = normalizeModelId(model); + if (normalized === "openrouter/auto") { + return true; + } + + if (normalized.startsWith("google/gemini-") && normalized.includes("image")) { + return true; + } + + if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) { + return true; + } + + return false; +} + +function getSupportedAspectRatios(model: string): Set { + const normalized = normalizeModelId(model); + if (normalized !== "google/gemini-3.1-flash-image-preview") { + return new Set(COMMON_ASPECT_RATIOS); + } + + return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]); +} + +function getApiKey(): string | null { + return process.env.OPENROUTER_API_KEY || null; +} + +function getBaseUrl(): string { + const base = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; + return base.replace(/\/+$/g, ""); +} + +function getHeaders(apiKey: string): Record { + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + + const referer = process.env.OPENROUTER_HTTP_REFERER?.trim(); + if (referer) { + headers["HTTP-Referer"] = referer; + } + + const title = process.env.OPENROUTER_TITLE?.trim(); + if (title) { + headers["X-OpenRouter-Title"] = title; + headers["X-Title"] = title; + } + + return headers; +} + +function parsePixelSize(value: string): { width: number; height: number } | null { + const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/); + if (!match) return null; + + const width = parseInt(match[1]!, 10); + const height = parseInt(match[2]!, 10); + + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + return { width, height }; +} + +function gcd(a: number, b: number): number { + let x = Math.abs(a); + let y = Math.abs(b); + while (y !== 0) { + const next = x % y; + x = y; + y = next; + } + return x || 1; +} + +function inferAspectRatio(size: string | null): string | null { + if (!size) return null; + const parsed = parsePixelSize(size); + if (!parsed) return null; + + const divisor = gcd(parsed.width, parsed.height); + return `${parsed.width / divisor}:${parsed.height / divisor}`; +} + +function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null { + if (!size) return null; + const parsed = parsePixelSize(size); + if (!parsed) return null; + + const longestEdge = Math.max(parsed.width, parsed.height); + if (longestEdge <= 1024) return "1K"; + if (longestEdge <= 2048) return "2K"; + return "4K"; +} + +export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null { + if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K"; + + const inferredFromSize = inferImageSize(args.size); + if (inferredFromSize) return inferredFromSize; + + if (args.quality === "normal") return "1K"; + if (args.quality === "2k") return "2K"; + return null; +} + +export function getAspectRatio(model: string, args: CliArgs): string | null { + if (args.aspectRatio) return args.aspectRatio; + + const inferred = inferAspectRatio(args.size); + if (!inferred || !getSupportedAspectRatios(model).has(inferred)) { + return null; + } + + return inferred; +} + +function getModalities(model: string): string[] { + return isTextAndImageModel(model) ? ["image", "text"] : ["image"]; +} + +export function validateArgs(model: string, args: CliArgs): void { + const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size); + if (!requestedAspectRatio) { + return; + } + + const supported = getSupportedAspectRatios(model); + if (supported.has(requestedAspectRatio)) { + return; + } + + const requestedValue = args.aspectRatio + ? `aspect ratio ${requestedAspectRatio}` + : `size ${args.size} (aspect ratio ${requestedAspectRatio})`; + + throw new Error( + `OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}` + ); +} + +function getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + if (ext === ".gif") return "image/gif"; + return "image/png"; +} + +async function readImageAsDataUrl(filePath: string): Promise { + const bytes = await readFile(filePath); + return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`; +} + +export function buildContent( + prompt: string, + referenceImages: string[], +): string | Array> { + if (referenceImages.length === 0) { + return prompt; + } + + const content: Array> = [{ type: "text", text: prompt }]; + + for (const imageUrl of referenceImages) { + content.push({ + type: "image_url", + image_url: { url: imageUrl }, + }); + } + + return content; +} + +function extractImageUrl(entry: OpenRouterImageEntry | OpenRouterMessagePart): string | null { + const value = entry.image_url ?? entry.imageUrl; + if (!value) return null; + if (typeof value === "string") return value; + return value.url ?? null; +} + +function decodeDataUrl(value: string): Uint8Array | null { + const match = value.match(/^data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)$/); + if (!match) return null; + return Uint8Array.from(Buffer.from(match[1]!, "base64")); +} + +async function downloadImage(value: string): Promise { + const inline = decodeDataUrl(value); + if (inline) return inline; + + if (value.startsWith("http://") || value.startsWith("https://")) { + const response = await fetch(value); + if (!response.ok) { + throw new Error(`Failed to download OpenRouter image: ${response.status}`); + } + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + return Uint8Array.from(Buffer.from(value, "base64")); +} + +export async function extractImageFromResponse(result: OpenRouterResponse): Promise { + const choice = result.choices?.[0]; + const message = choice?.message; + + for (const image of message?.images ?? []) { + const imageUrl = extractImageUrl(image); + if (imageUrl) return downloadImage(imageUrl); + } + + if (Array.isArray(message?.content)) { + for (const item of message.content) { + const imageUrl = extractImageUrl(item); + if (imageUrl) return downloadImage(imageUrl); + + if (item.type === "text" && item.text) { + const inline = decodeDataUrl(item.text); + if (inline) return inline; + } + } + } else if (typeof message?.content === "string") { + const inline = decodeDataUrl(message.content); + if (inline) return inline; + } + + const finishReason = + choice?.native_finish_reason || choice?.finish_reason || "unknown"; + throw new Error( + `No image in OpenRouter response (finish_reason=${finishReason})`, + ); +} + +export function buildRequestBody( + prompt: string, + model: string, + args: CliArgs, + referenceImages: string[], +): Record { + validateArgs(model, args); + + const imageConfig: Record = {}; + + const imageSize = getImageSize(args); + if (imageSize) { + imageConfig.image_size = imageSize; + } + + const aspectRatio = getAspectRatio(model, args); + if (aspectRatio) { + imageConfig.aspect_ratio = aspectRatio; + } + + const body: Record = { + messages: [ + { + role: "user", + content: buildContent(prompt, referenceImages), + }, + ], + modalities: getModalities(model), + stream: false, + }; + + if (Object.keys(imageConfig).length > 0) { + body.image_config = imageConfig; + body.provider = { + require_parameters: true, + }; + } + + return body; +} + +export async function generateImage( + prompt: string, + model: string, + args: CliArgs +): Promise { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is required. Get one at https://openrouter.ai/settings/keys"); + } + + const referenceImages: string[] = []; + for (const refPath of args.referenceImages) { + referenceImages.push(await readImageAsDataUrl(refPath)); + } + + const body = { + model, + ...buildRequestBody(prompt, model, args, referenceImages), + }; + + console.log( + `Generating image with OpenRouter (${model})...`, + (body.image_config as Record), + ); + + const response = await fetch(`${getBaseUrl()}/chat/completions`, { + method: "POST", + headers: getHeaders(apiKey), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error (${response.status}): ${errorText}`); + } + + const result = (await response.json()) as OpenRouterResponse; + return extractImageFromResponse(result); +} diff --git a/skills/baoyu-image-gen/scripts/providers/replicate.test.ts b/skills/baoyu-image-gen/scripts/providers/replicate.test.ts new file mode 100644 index 0000000..c52afb1 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/replicate.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildInput, + extractOutputUrl, + parseModelId, +} from "./replicate.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("Replicate model parsing accepts official formats and rejects malformed ones", () => { + assert.deepEqual(parseModelId("google/nano-banana-pro"), { + owner: "google", + name: "nano-banana-pro", + version: null, + }); + assert.deepEqual(parseModelId("owner/model:abc123"), { + owner: "owner", + name: "model", + version: "abc123", + }); + + assert.throws( + () => parseModelId("just-a-model-name"), + /Invalid Replicate model format/, + ); +}); + +test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => { + assert.deepEqual( + buildInput( + "A robot painter", + makeArgs({ + aspectRatio: "16:9", + quality: "2k", + n: 3, + }), + ["data:image/png;base64,AAAA"], + ), + { + prompt: "A robot painter", + aspect_ratio: "16:9", + number_of_images: 3, + resolution: "2K", + output_format: "png", + image_input: ["data:image/png;base64,AAAA"], + }, + ); + + assert.deepEqual( + buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]), + { + prompt: "A robot painter", + aspect_ratio: "match_input_image", + resolution: "1K", + output_format: "png", + image_input: ["ref"], + }, + ); +}); + +test("Replicate output extraction supports string, array, and object URLs", () => { + assert.equal( + extractOutputUrl({ output: "https://example.com/a.png" } as never), + "https://example.com/a.png", + ); + assert.equal( + extractOutputUrl({ output: ["https://example.com/b.png"] } as never), + "https://example.com/b.png", + ); + assert.equal( + extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never), + "https://example.com/c.png", + ); + + assert.throws( + () => extractOutputUrl({ output: { invalid: true } } as never), + /Unexpected Replicate output format/, + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/replicate.ts b/skills/baoyu-image-gen/scripts/providers/replicate.ts new file mode 100644 index 0000000..611d24e --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/replicate.ts @@ -0,0 +1,205 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { CliArgs } from "../types"; + +const DEFAULT_MODEL = "google/nano-banana-pro"; +const SYNC_WAIT_SECONDS = 60; +const POLL_INTERVAL_MS = 2000; +const MAX_POLL_MS = 300_000; + +export function getDefaultModel(): string { + return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL; +} + +function getApiToken(): string | null { + return process.env.REPLICATE_API_TOKEN || null; +} + +function getBaseUrl(): string { + const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com"; + return base.replace(/\/+$/g, ""); +} + +export function parseModelId(model: string): { owner: string; name: string; version: string | null } { + const [ownerName, version] = model.split(":"); + const parts = ownerName!.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".` + ); + } + return { owner: parts[0], name: parts[1], version: version || null }; +} + +export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record { + const input: Record = { prompt }; + + if (args.aspectRatio) { + input.aspect_ratio = args.aspectRatio; + } else if (referenceImages.length > 0) { + input.aspect_ratio = "match_input_image"; + } + + if (args.n > 1) { + input.number_of_images = args.n; + } + + if (args.quality === "normal") { + input.resolution = "1K"; + } else if (args.quality === "2k") { + input.resolution = "2K"; + } + + input.output_format = "png"; + + if (referenceImages.length > 0) { + input.image_input = referenceImages; + } + + return input; +} + +async function readImageAsDataUrl(p: string): Promise { + const buf = await readFile(p); + const ext = path.extname(p).toLowerCase(); + let mimeType = "image/png"; + if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg"; + else if (ext === ".gif") mimeType = "image/gif"; + else if (ext === ".webp") mimeType = "image/webp"; + return `data:${mimeType};base64,${buf.toString("base64")}`; +} + +type PredictionResponse = { + id: string; + status: string; + output: unknown; + error: string | null; + urls?: { get?: string }; +}; + +async function createPrediction( + apiToken: string, + model: { owner: string; name: string; version: string | null }, + input: Record, + sync: boolean +): Promise { + const baseUrl = getBaseUrl(); + + let url: string; + const body: Record = { input }; + + if (model.version) { + url = `${baseUrl}/v1/predictions`; + body.version = model.version; + } else { + url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`; + } + + const headers: Record = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }; + + if (sync) { + headers["Prefer"] = `wait=${SYNC_WAIT_SECONDS}`; + } + + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Replicate API error (${res.status}): ${err}`); + } + + return (await res.json()) as PredictionResponse; +} + +async function pollPrediction(apiToken: string, getUrl: string): Promise { + const start = Date.now(); + + while (Date.now() - start < MAX_POLL_MS) { + const res = await fetch(getUrl, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Replicate poll error (${res.status}): ${err}`); + } + + const prediction = (await res.json()) as PredictionResponse; + + if (prediction.status === "succeeded") return prediction; + if (prediction.status === "failed" || prediction.status === "canceled") { + throw new Error(`Replicate prediction ${prediction.status}: ${prediction.error || "unknown error"}`); + } + + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + + throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`); +} + +export function extractOutputUrl(prediction: PredictionResponse): string { + const output = prediction.output; + + if (typeof output === "string") return output; + + if (Array.isArray(output)) { + const first = output[0]; + if (typeof first === "string") return first; + } + + if (output && typeof output === "object" && "url" in output) { + const url = (output as Record).url; + if (typeof url === "string") return url; + } + + throw new Error(`Unexpected Replicate output format: ${JSON.stringify(output)}`); +} + +async function downloadImage(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to download image from Replicate: ${res.status}`); + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); +} + +export async function generateImage( + prompt: string, + model: string, + args: CliArgs +): Promise { + const apiToken = getApiToken(); + if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens"); + + const parsedModel = parseModelId(model); + + const refDataUrls: string[] = []; + for (const refPath of args.referenceImages) { + refDataUrls.push(await readImageAsDataUrl(refPath)); + } + + const input = buildInput(prompt, args, refDataUrls); + + console.log(`Generating image with Replicate (${model})...`); + + let prediction = await createPrediction(apiToken, parsedModel, input, true); + + if (prediction.status !== "succeeded") { + if (!prediction.urls?.get) { + throw new Error("Replicate prediction did not return a poll URL"); + } + console.log("Waiting for prediction to complete..."); + prediction = await pollPrediction(apiToken, prediction.urls.get); + } + + console.log("Generation completed."); + + const outputUrl = extractOutputUrl(prediction); + return downloadImage(outputUrl); +} diff --git a/skills/baoyu-image-gen/scripts/providers/seedream.test.ts b/skills/baoyu-image-gen/scripts/providers/seedream.test.ts new file mode 100644 index 0000000..5ec94d6 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/seedream.test.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildImageInput, + buildRequestBody, + generateImage, + getDefaultOutputExtension, + resolveSeedreamSize, + validateArgs, +} from "./seedream.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + t.after(() => { + for (const [key, value] of previous.entries()) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +async function makeTempPng(t: TestContext, name: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "seedream-test-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const filePath = path.join(dir, name); + const png1x1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7m0AAAAASUVORK5CYII="; + await fs.writeFile(filePath, Buffer.from(png1x1, "base64")); + return filePath; +} + +test("Seedream request body and default extensions follow official model capabilities", () => { + const five = buildRequestBody( + "A robot illustrator", + "doubao-seedream-5-0-260128", + makeArgs(), + ); + assert.equal(five.size, "2K"); + assert.equal(five.response_format, "url"); + assert.equal(five.output_format, "png"); + assert.equal(getDefaultOutputExtension("doubao-seedream-5-0-260128"), ".png"); + + const fourFive = buildRequestBody( + "A robot illustrator", + "doubao-seedream-4-5-251128", + makeArgs(), + ); + assert.equal(fourFive.size, "2K"); + assert.equal(fourFive.response_format, "url"); + assert.ok(!("output_format" in fourFive)); + assert.equal(getDefaultOutputExtension("doubao-seedream-4-5-251128"), ".jpg"); + + assert.throws( + () => + buildRequestBody( + "Change the bubbles into hearts", + "doubao-seededit-3-0-i2i-250628", + makeArgs({ referenceImages: ["ref.png"] }), + "data:image/png;base64,AAAA", + ), + /no longer supported/, + ); +}); + +test("Seedream size selection validates model-specific presets", () => { + assert.equal( + resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })), + "1K", + ); + assert.equal( + resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ quality: "2k" })), + "2048x2048", + ); + + assert.throws( + () => + resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ size: "4K" })), + /only supports 2K, 3K/, + ); + assert.throws( + () => + resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })), + /only supports explicit WxH sizes/, + ); + assert.throws( + () => + resolveSeedreamSize("doubao-seededit-3-0-i2i-250628", makeArgs({ size: "1024x1024" })), + /no longer supported/, + ); +}); + +test("Seedream reference-image support is model-specific", () => { + assert.doesNotThrow(() => + validateArgs( + "doubao-seedream-5-0-260128", + makeArgs({ referenceImages: ["a.png", "b.png"] }), + ), + ); + + assert.throws( + () => + validateArgs( + "doubao-seedream-3-0-t2i-250415", + makeArgs({ referenceImages: ["a.png"] }), + ), + /does not support reference images/, + ); + + assert.throws( + () => + validateArgs( + "doubao-seededit-3-0-i2i-250628", + makeArgs(), + ), + /no longer supported/, + ); + + assert.throws( + () => + validateArgs( + "ep-20260315171508-t8br2", + makeArgs({ referenceImages: ["a.png"] }), + ), + /require a known model ID/, + ); +}); + +test("Seedream image input encodes local references as data URLs", async (t) => { + const refOne = await makeTempPng(t, "one.png"); + const refTwo = await makeTempPng(t, "two.png"); + + const single = await buildImageInput("doubao-seedream-4-5-251128", [refOne]); + assert.match(String(single), /^data:image\/png;base64,/); + + const multiple = await buildImageInput("doubao-seedream-5-0-260128", [refOne, refTwo]); + assert.ok(Array.isArray(multiple)); + assert.equal(multiple.length, 2); +}); + +test("Seedream generateImage posts the documented response_format and downloads the returned URL", async (t) => { + useEnv(t, { ARK_API_KEY: "test-key", SEEDREAM_BASE_URL: null }); + + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const calls: Array<{ + input: string; + init?: RequestInit; + }> = []; + + globalThis.fetch = async (input, init) => { + calls.push({ + input: String(input), + init, + }); + + if (calls.length === 1) { + return Response.json({ + model: "doubao-seedream-4-5-251128", + created: 1740000000, + data: [ + { + url: "https://example.com/generated-image", + size: "2048x2048", + }, + ], + usage: { + generated_images: 1, + output_tokens: 1, + total_tokens: 1, + }, + }); + } + + return new Response(Uint8Array.from([7, 8, 9]), { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }); + }; + + const image = await generateImage( + "A robot illustrator", + "doubao-seedream-4-5-251128", + makeArgs(), + ); + + assert.deepEqual([...image], [7, 8, 9]); + assert.equal(calls.length, 2); + assert.equal( + calls[0]?.input, + "https://ark.cn-beijing.volces.com/api/v3/images/generations", + ); + + const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record; + assert.equal(requestBody.model, "doubao-seedream-4-5-251128"); + assert.equal(requestBody.size, "2K"); + assert.equal(requestBody.response_format, "url"); + assert.ok(!("output_format" in requestBody)); + assert.equal(calls[1]?.input, "https://example.com/generated-image"); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/seedream.ts b/skills/baoyu-image-gen/scripts/providers/seedream.ts new file mode 100644 index 0000000..436a236 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/seedream.ts @@ -0,0 +1,341 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import type { CliArgs } from "../types"; + +export type SeedreamModelFamily = + | "seedream5" + | "seedream45" + | "seedream40" + | "seedream30" + | "unknown"; + +type SeedreamRequestImage = string | string[]; + +type SeedreamRequestBody = { + model: string; + prompt: string; + size: string; + response_format: "url"; + watermark: boolean; + image?: SeedreamRequestImage; + output_format?: "png"; +}; + +type SeedreamImageResponse = { + model?: string; + created?: number; + data?: Array<{ + url?: string; + b64_json?: string; + size?: string; + error?: { + code?: string; + message?: string; + }; + }>; + usage?: { + generated_images: number; + output_tokens: number; + total_tokens: number; + }; + error?: { + code?: string; + message?: string; + }; +}; + +export function getDefaultModel(): string { + return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128"; +} + +function getApiKey(): string | null { + return process.env.ARK_API_KEY || null; +} + +function getBaseUrl(): string { + return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3"; +} + +function parsePixelSize(value: string): { width: number; height: number } | null { + const match = value.trim().match(/^(\d+)\s*[xX]\s*(\d+)$/); + if (!match) return null; + + const width = parseInt(match[1]!, 10); + const height = parseInt(match[2]!, 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + return { width, height }; +} + +function normalizePixelSize(value: string): string | null { + const parsed = parsePixelSize(value); + if (!parsed) return null; + return `${parsed.width}x${parsed.height}`; +} + +function normalizeSizePreset(value: string): string | null { + const upper = value.trim().toUpperCase(); + if (upper === "ADAPTIVE") return "adaptive"; + if (upper === "1K" || upper === "2K" || upper === "3K" || upper === "4K") return upper; + return null; +} + +function normalizeSizeValue(value: string): string | null { + return normalizeSizePreset(value) ?? normalizePixelSize(value); +} + +function getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + if (ext === ".gif") return "image/gif"; + if (ext === ".bmp") return "image/bmp"; + if (ext === ".tiff" || ext === ".tif") return "image/tiff"; + return "image/png"; +} + +async function readImageAsDataUrl(filePath: string): Promise { + const bytes = await readFile(filePath); + return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`; +} + +export function getModelFamily(model: string): SeedreamModelFamily { + const normalized = model.trim(); + if (/^doubao-seedream-5-0(?:-lite)?-\d+$/.test(normalized)) return "seedream5"; + if (/^doubao-seedream-4-5-\d+$/.test(normalized)) return "seedream45"; + if (/^doubao-seedream-4-0-\d+$/.test(normalized)) return "seedream40"; + if (/^doubao-seedream-3-0-t2i-\d+$/.test(normalized)) return "seedream30"; + return "unknown"; +} + +function isRemovedSeededitModel(model: string): boolean { + return /^doubao-seededit-3-0-i2i-\d+$/.test(model.trim()); +} + +function assertSupportedModel(model: string): void { + if (isRemovedSeededitModel(model)) { + throw new Error( + `${model} is no longer supported. SeedEdit 3.0 support has been removed from this tool; use Seedream 5.0/4.5/4.0/3.0 instead.` + ); + } +} + +export function supportsReferenceImages(model: string): boolean { + const family = getModelFamily(model); + return family === "seedream5" || family === "seedream45" || family === "seedream40"; +} + +function supportsOutputFormat(model: string): boolean { + return getModelFamily(model) === "seedream5"; +} + +export function getDefaultOutputExtension(model: string): ".png" | ".jpg" { + assertSupportedModel(model); + return supportsOutputFormat(model) ? ".png" : ".jpg"; +} + +export function getDefaultSeedreamSize(model: string, args: CliArgs): string { + assertSupportedModel(model); + const family = getModelFamily(model); + + if (family === "seedream5") return "2K"; + if (family === "seedream45") return "2K"; + if (family === "seedream40") return args.quality === "normal" ? "1K" : "2K"; + if (family === "seedream30") return args.quality === "2k" ? "2048x2048" : "1024x1024"; + return "2K"; +} + +export function resolveSeedreamSize(model: string, args: CliArgs): string { + assertSupportedModel(model); + const family = getModelFamily(model); + const requested = args.size || args.imageSize || null; + const normalized = requested ? normalizeSizeValue(requested) : null; + + if (!normalized) { + return getDefaultSeedreamSize(model, args); + } + + if (family === "seedream30") { + const pixelSize = normalizePixelSize(normalized); + if (!pixelSize) { + throw new Error("Seedream 3.0 only supports explicit WxH sizes such as 1024x1024."); + } + return pixelSize; + } + + if (family === "seedream5") { + if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") { + throw new Error("Seedream 5.0 only supports 2K, 3K, or explicit WxH sizes."); + } + return normalized; + } + + if (family === "seedream45") { + if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") { + throw new Error("Seedream 4.5 only supports 2K, 4K, or explicit WxH sizes."); + } + return normalized; + } + + if (family === "seedream40") { + if (normalized === "3K" || normalized === "adaptive") { + throw new Error("Seedream 4.0 only supports 1K, 2K, 4K, or explicit WxH sizes."); + } + return normalized; + } + + if (normalized === "adaptive") { + throw new Error("Adaptive size is not supported by Seedream image generation."); + } + + if (normalized === "1K" || normalized === "3K" || normalized === "4K") { + throw new Error( + "Unknown Seedream model ID. Use a documented model ID or pass an explicit WxH size instead of preset imageSize." + ); + } + + return normalized; +} + +export function validateArgs(model: string, args: CliArgs): void { + assertSupportedModel(model); + const family = getModelFamily(model); + const refCount = args.referenceImages.length; + + if (refCount === 0) { + resolveSeedreamSize(model, args); + return; + } + + if (family === "unknown") { + throw new Error( + "Reference images with Seedream require a known model ID. Use Seedream 5.0/4.5/4.0 model IDs instead of an endpoint ID." + ); + } + + if (!supportsReferenceImages(model)) { + throw new Error(`${model} does not support reference images.`); + } + + if ((family === "seedream5" || family === "seedream45" || family === "seedream40") && refCount > 14) { + throw new Error(`${model} supports at most 14 reference images.`); + } + + resolveSeedreamSize(model, args); +} + +export async function buildImageInput( + model: string, + referenceImages: string[], +): Promise { + if (referenceImages.length === 0) return undefined; + assertSupportedModel(model); + + const encoded = await Promise.all(referenceImages.map((refPath) => readImageAsDataUrl(refPath))); + + return encoded.length === 1 ? encoded[0]! : encoded; +} + +export function buildRequestBody( + prompt: string, + model: string, + args: CliArgs, + imageInput?: SeedreamRequestImage, +): SeedreamRequestBody { + validateArgs(model, args); + + const requestBody: SeedreamRequestBody = { + model, + prompt, + size: resolveSeedreamSize(model, args), + response_format: "url", + watermark: false, + }; + + if (imageInput) { + requestBody.image = imageInput; + } + + if (supportsOutputFormat(model)) { + requestBody.output_format = "png"; + } + + return requestBody; +} + +async function downloadImage(url: string): Promise { + const imgResponse = await fetch(url); + if (!imgResponse.ok) { + throw new Error(`Failed to download image from ${url}`); + } + + const buffer = await imgResponse.arrayBuffer(); + return new Uint8Array(buffer); +} + +export async function extractImageFromResponse(result: SeedreamImageResponse): Promise { + const first = result.data?.find((item) => item.url || item.b64_json || item.error); + + if (!first) { + throw new Error("No image data in Seedream response"); + } + + if (first.error) { + throw new Error(first.error.message || "Seedream returned an image generation error"); + } + + if (first.b64_json) { + return Uint8Array.from(Buffer.from(first.b64_json, "base64")); + } + + if (first.url) { + console.error(`Downloading image from ${first.url}...`); + return downloadImage(first.url); + } + + throw new Error("No image URL or base64 data in Seedream response"); +} + +export async function generateImage( + prompt: string, + model: string, + args: CliArgs, +): Promise { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error( + "ARK_API_KEY is required. " + + "Get your API key from https://console.volcengine.com/ark" + ); + } + + validateArgs(model, args); + const imageInput = await buildImageInput(model, args.referenceImages); + const requestBody = buildRequestBody(prompt, model, args, imageInput); + + console.error(`Calling Seedream API (${model}) with size: ${requestBody.size}`); + + const response = await fetch(`${getBaseUrl()}/images/generations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Seedream API error (${response.status}): ${err}`); + } + + const result = (await response.json()) as SeedreamImageResponse; + if (result.error) { + throw new Error(result.error.message || "Seedream API returned an error"); + } + + return extractImageFromResponse(result); +} diff --git a/skills/baoyu-image-gen/scripts/types.ts b/skills/baoyu-image-gen/scripts/types.ts new file mode 100644 index 0000000..dd98213 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/types.ts @@ -0,0 +1,82 @@ +export type Provider = + | "google" + | "openai" + | "openrouter" + | "dashscope" + | "minimax" + | "replicate" + | "jimeng" + | "seedream" + | "azure"; +export type Quality = "normal" | "2k"; + +export type CliArgs = { + prompt: string | null; + promptFiles: string[]; + imagePath: string | null; + provider: Provider | null; + model: string | null; + aspectRatio: string | null; + size: string | null; + quality: Quality | null; + imageSize: string | null; + referenceImages: string[]; + n: number; + batchFile: string | null; + jobs: number | null; + json: boolean; + help: boolean; +}; + +export type BatchTaskInput = { + id?: string; + prompt?: string | null; + promptFiles?: string[]; + image?: string; + provider?: Provider | null; + model?: string | null; + ar?: string | null; + size?: string | null; + quality?: Quality | null; + imageSize?: "1K" | "2K" | "4K" | null; + ref?: string[]; + n?: number; +}; + +export type BatchFile = + | BatchTaskInput[] + | { + tasks: BatchTaskInput[]; + jobs?: number | null; + }; + +export type ExtendConfig = { + version: number; + default_provider: Provider | null; + default_quality: Quality | null; + default_aspect_ratio: string | null; + default_image_size: "1K" | "2K" | "4K" | null; + default_model: { + google: string | null; + openai: string | null; + openrouter: string | null; + dashscope: string | null; + minimax: string | null; + replicate: string | null; + jimeng: string | null; + seedream: string | null; + azure: string | null; + }; + batch?: { + max_workers?: number | null; + provider_limits?: Partial< + Record< + Provider, + { + concurrency?: number | null; + start_interval_ms?: number | null; + } + > + >; + }; +};