diff --git a/skills/baoyu-imagine/SKILL.md b/skills/baoyu-imagine/SKILL.md index 06c01e8..9b5ef2f 100644 --- a/skills/baoyu-imagine/SKILL.md +++ b/skills/baoyu-imagine/SKILL.md @@ -91,7 +91,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 | `--quality normal\|2k` | Quality preset (default: `2k`) | | `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) | | `--imageApiDialect openai-native\|ratio-metadata` | OpenAI-compatible endpoint dialect — use `ratio-metadata` for gateways that expect aspect-ratio `size` plus `metadata.resolution` | -| `--ref ` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0 | +| `--ref ` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0, DashScope `wan2.7-image-pro`/`wan2.7-image`. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0, or any DashScope model outside the `wan2.7-image*` family | | `--n ` | Number of images. Replicate requires `--n 1` (single-output save semantics) | | `--json` | JSON output | diff --git a/skills/baoyu-imagine/references/config/first-time-setup.md b/skills/baoyu-imagine/references/config/first-time-setup.md index d92222a..d342ebc 100644 --- a/skills/baoyu-imagine/references/config/first-time-setup.md +++ b/skills/baoyu-imagine/references/config/first-time-setup.md @@ -271,6 +271,10 @@ options: description: "Legacy Qwen model with five fixed output sizes" - label: "qwen-image-plus" description: "Legacy Qwen model, same current capability as qwen-image" + - label: "wan2.7-image-pro" + description: "Wan 2.7 Pro — supports up to 4K text-to-image and reference-image editing" + - label: "wan2.7-image" + description: "Wan 2.7 base — faster generation, up to 2K, supports reference-image editing" - label: "z-image-turbo" description: "Legacy DashScope model for compatibility" - label: "z-image-ultra" @@ -281,6 +285,7 @@ Notes for DashScope setup: - Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering. - `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`. +- `wan2.7-image-pro` and `wan2.7-image` are the only DashScope models that accept `--ref`. Pick one of these when the user wants reference-image editing or multi-image fusion via DashScope. - In `baoyu-imagine`, `quality` is a compatibility preset. It is not a native DashScope parameter. ### Z.AI Model Selection diff --git a/skills/baoyu-imagine/references/providers/dashscope.md b/skills/baoyu-imagine/references/providers/dashscope.md index 92fe4d0..f3a7d5a 100644 --- a/skills/baoyu-imagine/references/providers/dashscope.md +++ b/skills/baoyu-imagine/references/providers/dashscope.md @@ -17,6 +17,17 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`, - Default is `1664*928` - `qwen-image` currently has the same capability as `qwen-image-plus` +**`wan2.7-image*`** — multimodal Wan 2.7 family. Members: `wan2.7-image-pro`, `wan2.7-image`. + +- Free-form `size` in `宽*高` format, plus aspect-ratio inference +- `wan2.7-image-pro` text-to-image (no `--ref`): total pixels in `[768*768, 4096*4096]`, ratio in `[1:8, 8:1]` +- `wan2.7-image-pro` with reference images and `wan2.7-image` (all scenarios): total pixels in `[768*768, 2048*2048]`, ratio in `[1:8, 8:1]` +- Default: `1024*1024` (`--quality normal`) or `2048*2048` (`--quality 2k`); 4K requires explicit `--size` +- Supports up to 9 reference images in `--ref` (image editing / multi-image fusion) +- Reference images are sent inline as base64 (or passed through if the path is an `http(s)://` URL) +- API does NOT use `prompt_extend`; the skill omits it for this family +- The Wan 2.7 API defaults `n` to **4** in non-collage mode and bills per generated image. baoyu-imagine forces `n: 1` and rejects `--n > 1` to avoid silently paying for and discarding extra images. + **Legacy** — `z-image-turbo`, `z-image-ultra`, `wanx-v1`. Only use when the user explicitly asks for legacy behavior. ## Size Resolution @@ -24,7 +35,8 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`, - `--size` wins over `--ar` - For `qwen-image-2.0*`: prefer explicit `--size`; otherwise infer from `--ar` using the recommended table below - For `qwen-image-max/plus/image`: only use the five fixed sizes; if the requested ratio doesn't fit, switch to `qwen-image-2.0-pro` -- `--quality` is a baoyu-imagine preset, not an official DashScope field. The mapping of `normal`/`2k` onto the `qwen-image-2.0*` table is an implementation choice, not an API guarantee +- For `wan2.7-image*`: explicit `--size` is validated against the per-mode pixel/ratio limits; otherwise the size is derived from `--ar` and `--quality` (`normal` ≈ 1K, `2k` ≈ 2K). To request 4K with `wan2.7-image-pro` text-to-image, pass `--size` explicitly (e.g. `4096*4096`, `3840*2160`) +- `--quality` is a baoyu-imagine preset, not an official DashScope field. The mapping of `normal`/`2k` onto the `qwen-image-2.0*` and `wan2.7-image*` tables is an implementation choice, not an API guarantee ### Recommended `qwen-image-2.0*` sizes @@ -39,12 +51,19 @@ Read when the user picks `--provider dashscope`, sets `default_model.dashscope`, | `16:9` | `1280*720` | `1920*1080` | | `21:9` | `1344*576` | `2048*872` | +## Reference Images + +- Only `wan2.7-image-pro` and `wan2.7-image` accept `--ref`. Other DashScope models (qwen-image-2.0*, qwen-image-max/plus/image, legacy) reject `--ref` and the user is steered to a different provider/model. +- Up to 9 reference images per request. Local files are inlined as base64 data URLs; `http(s)://` URLs are forwarded as-is. +- Supplying any `--ref` automatically clamps the wan2.7-image-pro pixel ceiling from 4K to 2K (the API only supports 4K for pure text-to-image with no image input). + ## Not Exposed -DashScope APIs also support `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-imagine` does not expose them as CLI flags today. +DashScope APIs also support `negative_prompt`, `prompt_extend`, `watermark`, `thinking_mode`, `seed`, `bbox_list`, `enable_sequential`, and `color_palette`. `baoyu-imagine` does not expose them as CLI flags today; the wan2.7 family relies on the API defaults (e.g. `thinking_mode=true`). The skill always sends `n=1` for wan2.7 — if you want grid/collage mode you currently need to call the API directly. ## Official References - [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api) - [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image) - [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api) +- [Wan 2.7 image generation & editing API](https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference) diff --git a/skills/baoyu-imagine/references/usage-examples.md b/skills/baoyu-imagine/references/usage-examples.md index 0bfb29d..d148bc0 100644 --- a/skills/baoyu-imagine/references/usage-examples.md +++ b/skills/baoyu-imagine/references/usage-examples.md @@ -51,6 +51,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 # DashScope legacy fixed-size ${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928 +# DashScope Wan 2.7 Image Pro (4K text-to-image) +${BUN_X} {baseDir}/scripts/main.ts --prompt "一间有着精致窗户的花店" --image out.png --provider dashscope --model wan2.7-image-pro --size 4096x4096 + +# DashScope Wan 2.7 Image with reference image (multi-image fusion) +${BUN_X} {baseDir}/scripts/main.ts --prompt "把图2的涂鸦喷绘在图1的汽车上" --image out.png --provider dashscope --model wan2.7-image-pro --ref car.webp paint.webp + # Z.AI GLM-image ${BUN_X} {baseDir}/scripts/main.ts --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai diff --git a/skills/baoyu-imagine/scripts/main.test.ts b/skills/baoyu-imagine/scripts/main.test.ts index 8213bb9..beefd13 100644 --- a/skills/baoyu-imagine/scripts/main.test.ts +++ b/skills/baoyu-imagine/scripts/main.test.ts @@ -19,6 +19,7 @@ import { parseArgs, parseOpenAIImageApiDialect, parseSimpleYaml, + validateReferenceImages, } from "./main.ts"; function makeArgs(overrides: Partial = {}): CliArgs { @@ -123,6 +124,15 @@ test("parseArgs falls back to positional prompt and rejects invalid provider", ( ); }); +test("validateReferenceImages can skip remote URLs for providers that support them", async () => { + await validateReferenceImages(["https://example.com/ref.png"], { allowRemoteUrls: true }); + + await assert.rejects( + () => validateReferenceImages(["https://example.com/ref.png"]), + /Reference image not found/, + ); +}); + test("parseSimpleYaml parses nested defaults and provider limits", () => { const yaml = ` version: 2 @@ -308,7 +318,7 @@ test("detectProvider rejects non-ref-capable providers and prefers Google first () => detectProvider( makeArgs({ - provider: "dashscope", + provider: "zai", referenceImages: ["ref.png"], }), ), @@ -426,6 +436,33 @@ test("detectProvider infers Seedream from model id and allows Seedream reference ); }); +test("detectProvider allows DashScope reference-image workflows when explicitly chosen for wan2.7 models", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: null, + AZURE_OPENAI_API_KEY: null, + AZURE_OPENAI_BASE_URL: null, + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: "dashscope-key", + MINIMAX_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + + assert.equal( + detectProvider( + makeArgs({ + provider: "dashscope", + model: "wan2.7-image-pro", + referenceImages: ["ref.png"], + }), + ), + "dashscope", + ); +}); + test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => { useEnv(t, { GOOGLE_API_KEY: null, @@ -504,7 +541,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) id: "hero", promptFiles: ["prompts/hero.md"], image: "out/hero", - ref: ["refs/hero.png"], + ref: ["refs/hero.png", "https://example.com/ref.png"], ar: "16:9", }, ], @@ -533,6 +570,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero")); assert.deepEqual(taskArgs.referenceImages, [ path.join(loaded.batchDir, "refs/hero.png"), + "https://example.com/ref.png", ]); assert.equal(taskArgs.provider, "replicate"); assert.equal(taskArgs.aspectRatio, "16:9"); @@ -557,5 +595,29 @@ test("path normalization, worker count, and retry classification follow expected ), false, ); + assert.equal( + isRetryableGenerationError( + new Error("DashScope wan2.7 image models accept at most 9 reference images. Received 10."), + ), + false, + ); + assert.equal( + isRetryableGenerationError( + new Error("DashScope wan2.7 image models in baoyu-imagine support exactly one output image per request."), + ), + false, + ); + assert.equal( + isRetryableGenerationError( + new Error("DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]."), + ), + false, + ); + assert.equal( + isRetryableGenerationError( + new Error("DashScope wan2.7-image requires total pixels between 768*768 and 2048*2048."), + ), + false, + ); assert.equal(isRetryableGenerationError(new Error("socket hang up")), true); }); diff --git a/skills/baoyu-imagine/scripts/main.ts b/skills/baoyu-imagine/scripts/main.ts index ea3bf4c..967a765 100644 --- a/skills/baoyu-imagine/scripts/main.ts +++ b/skills/baoyu-imagine/scripts/main.ts @@ -85,7 +85,7 @@ Options: --quality normal|2k Quality preset (default: 2k) --imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality) --imageApiDialect OpenAI-compatible image dialect: openai-native|ratio-metadata - --ref Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, or Seedream 4.0/4.5/5.0) + --ref Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*) --n Number of images for the current task (default: 1; Replicate currently requires 1) --json JSON output -h, --help Show help @@ -698,10 +698,11 @@ export function detectProvider(args: CliArgs): Provider { args.provider !== "openrouter" && args.provider !== "replicate" && args.provider !== "seedream" && - args.provider !== "minimax" + args.provider !== "minimax" && + args.provider !== "dashscope" ) { throw new Error( - "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows." + "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows." ); } @@ -775,8 +776,24 @@ export function detectProvider(args: CliArgs): Provider { ); } -export async function validateReferenceImages(referenceImages: string[]): Promise { +export type ReferenceImageValidationOptions = { + allowRemoteUrls?: boolean; +}; + +function isRemoteReferenceImage(refPath: string): boolean { + return /^https?:\/\//i.test(refPath); +} + +function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean { + return provider === "dashscope"; +} + +export async function validateReferenceImages( + referenceImages: string[], + options: ReferenceImageValidationOptions = {}, +): Promise { for (const refPath of referenceImages) { + if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue; const fullPath = path.resolve(refPath); try { await access(fullPath); @@ -803,6 +820,11 @@ export function isRetryableGenerationError(error: unknown): boolean { "API error (404)", "temporarily disabled", "supports saving exactly one image", + "supports only", + "support exactly one output image", + "support aspect ratios in", + "requires total pixels between", + "accept at most", ]; return !nonRetryableMarkers.some((marker) => msg.includes(marker)); } @@ -858,7 +880,11 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial 0) await validateReferenceImages(args.referenceImages); + if (args.referenceImages.length > 0) { + await validateReferenceImages(args.referenceImages, { + allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider), + }); + } const provider = detectProvider(args); const providerModule = await loadProviderModule(provider); @@ -907,6 +933,10 @@ export function resolveBatchPath(batchDir: string, filePath: string): string { return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath); } +function resolveBatchReferencePath(batchDir: string, filePath: string): string { + return isRemoteReferenceImage(filePath) ? filePath : resolveBatchPath(batchDir, filePath); +} + export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs { return { ...baseArgs, @@ -922,7 +952,7 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir imageSize: task.imageSize ?? baseArgs.imageSize ?? null, imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null), imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null, - referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [], + referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [], n: task.n ?? baseArgs.n, batchFile: null, jobs: baseArgs.jobs, @@ -946,7 +976,11 @@ async function prepareBatchTasks( const prompt = await loadPromptForArgs(taskArgs); if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`); if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`); - if (taskArgs.referenceImages.length > 0) await validateReferenceImages(taskArgs.referenceImages); + if (taskArgs.referenceImages.length > 0) { + await validateReferenceImages(taskArgs.referenceImages, { + allowRemoteUrls: shouldAllowRemoteReferenceImages(taskArgs.provider), + }); + } const provider = detectProvider(taskArgs); const providerModule = await loadProviderModule(provider); diff --git a/skills/baoyu-imagine/scripts/providers/dashscope.test.ts b/skills/baoyu-imagine/scripts/providers/dashscope.test.ts index ab6100a..b0fd80a 100644 --- a/skills/baoyu-imagine/scripts/providers/dashscope.test.ts +++ b/skills/baoyu-imagine/scripts/providers/dashscope.test.ts @@ -2,15 +2,42 @@ import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { + generateImage, getDefaultModel, getModelFamily, getQwen2SizeFromAspectRatio, getSizeFromAspectRatio, + getWan27SizeFromAspectRatio, normalizeSize, parseAspectRatio, parseSize, resolveSizeForModel, } from "./dashscope.ts"; +import type { CliArgs } from "../types.ts"; + +function makeCliArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: "dashscope", + model: null, + aspectRatio: null, + aspectRatioSource: null, + size: null, + quality: "2k", + imageSize: null, + imageSizeSource: null, + imageApiDialect: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} function useEnv( t: TestContext, @@ -51,9 +78,11 @@ test("DashScope aspect-ratio parsing accepts numeric ratios only", () => { assert.equal(parseAspectRatio("-1:2"), null); }); -test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => { +test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, wan2.7, and legacy models", () => { assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2"); assert.equal(getModelFamily("qwen-image"), "qwenFixed"); + assert.equal(getModelFamily("wan2.7-image"), "wan27"); + assert.equal(getModelFamily("wan2.7-image-pro"), "wan27"); assert.equal(getModelFamily("z-image-turbo"), "legacy"); assert.equal(getModelFamily("wanx-v1"), "legacy"); }); @@ -146,3 +175,218 @@ test("DashScope size normalization converts WxH into provider format", () => { assert.equal(normalizeSize("1024x1024"), "1024*1024"); assert.equal(normalizeSize("2048*1152"), "2048*1152"); }); + +test("Wan 2.7 derives sizes that match the requested ratio at the chosen pixel budget", () => { + const square2k = getWan27SizeFromAspectRatio(null, "2k", 2048 * 2048); + const parsedSquare = parseSize(square2k); + assert.ok(parsedSquare); + assert.equal(parsedSquare.width, parsedSquare.height); + assert.ok(parsedSquare.width * parsedSquare.height <= 2048 * 2048); + + const widescreen = getWan27SizeFromAspectRatio("16:9", "2k", 2048 * 2048); + const parsedWide = parseSize(widescreen); + assert.ok(parsedWide); + assert.ok(Math.abs(parsedWide.width / parsedWide.height - 16 / 9) < 0.05); + assert.ok(parsedWide.width * parsedWide.height <= 2048 * 2048); + + const pro4k = getWan27SizeFromAspectRatio("16:9", "2k", 4096 * 4096); + const parsed4k = parseSize(pro4k); + assert.ok(parsed4k); + assert.ok(parsed4k.width * parsed4k.height > 2048 * 2048); + assert.ok(parsed4k.width * parsed4k.height <= 4096 * 4096); +}); + +test("Wan 2.7 rejects aspect ratios outside the [1:8, 8:1] range", () => { + assert.throws( + () => getWan27SizeFromAspectRatio("9:1", "2k", 2048 * 2048), + /1:8, 8:1/, + ); + assert.throws( + () => getWan27SizeFromAspectRatio("1:9", "normal", 2048 * 2048), + /1:8, 8:1/, + ); +}); + +test("Wan 2.7 derived sizes stay inside the boundary ratio limits after rounding", () => { + for (const ar of ["8:1", "1:8"]) { + const size = getWan27SizeFromAspectRatio(ar, "2k", 2048 * 2048); + const parsed = parseSize(size); + assert.ok(parsed); + const ratio = parsed.width / parsed.height; + assert.ok(ratio >= 1 / 8); + assert.ok(ratio <= 8); + assert.ok(parsed.width * parsed.height <= 2048 * 2048); + } +}); + +test("resolveSizeForModel routes wan2.7-image to the 2K-capped derivation", () => { + const size = resolveSizeForModel("wan2.7-image", { + size: null, + aspectRatio: "16:9", + quality: "2k", + }); + const parsed = parseSize(size); + assert.ok(parsed); + assert.ok(parsed.width * parsed.height <= 2048 * 2048); + assert.ok(Math.abs(parsed.width / parsed.height - 16 / 9) < 0.05); +}); + +test("resolveSizeForModel allows wan2.7-image-pro 4K only when there are no reference images", () => { + assert.equal( + resolveSizeForModel("wan2.7-image-pro", { + size: "4096*4096", + aspectRatio: null, + quality: "2k", + }), + "4096*4096", + ); + + assert.throws( + () => + resolveSizeForModel("wan2.7-image-pro", { + size: "4096*4096", + aspectRatio: null, + quality: "2k", + referenceImages: ["a.png"], + }), + /total pixels between 768\*768 and 2048\*2048/, + ); + + const proWithRef = resolveSizeForModel("wan2.7-image-pro", { + size: null, + aspectRatio: "1:1", + quality: "2k", + referenceImages: ["a.png"], + }); + const parsedRef = parseSize(proWithRef); + assert.ok(parsedRef); + assert.ok(parsedRef.width * parsedRef.height <= 2048 * 2048); +}); + +test("Wan 2.7 request body forces n=1 and omits prompt_extend / negative_prompt", async (t) => { + useEnv(t, { DASHSCOPE_API_KEY: "fake-key" }); + + const originalFetch = globalThis.fetch; + let capturedBody: any = null; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + output: { + choices: [ + { + message: { + content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }], + }, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + await generateImage("hello", "wan2.7-image-pro", makeCliArgs({ aspectRatio: "1:1" })); + + assert.equal(capturedBody.model, "wan2.7-image-pro"); + assert.deepEqual(Object.keys(capturedBody.parameters).sort(), ["n", "size", "watermark"]); + assert.equal(capturedBody.parameters.n, 1); + assert.equal(capturedBody.parameters.watermark, false); + assert.equal(typeof capturedBody.parameters.size, "string"); + assert.ok(!("prompt_extend" in capturedBody.parameters)); + assert.ok(!("negative_prompt" in capturedBody.parameters)); + + assert.deepEqual(capturedBody.input.messages[0].content, [{ text: "hello" }]); +}); + +test("Wan 2.7 request body forwards remote reference image URLs", async (t) => { + useEnv(t, { DASHSCOPE_API_KEY: "fake-key" }); + + const originalFetch = globalThis.fetch; + let capturedBody: any = null; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + output: { + choices: [ + { + message: { + content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }], + }, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + await generateImage( + "combine these", + "wan2.7-image-pro", + makeCliArgs({ referenceImages: ["https://example.com/ref.png"] }), + ); + + assert.deepEqual(capturedBody.input.messages[0].content, [ + { image: "https://example.com/ref.png" }, + { text: "combine these" }, + ]); +}); + +test("Wan 2.7 rejects --n > 1 to prevent silent multi-image billing", async (t) => { + useEnv(t, { DASHSCOPE_API_KEY: "fake-key" }); + + await assert.rejects( + () => generateImage("hi", "wan2.7-image-pro", makeCliArgs({ n: 2 })), + /support exactly one output image/, + ); +}); + +test("resolveSizeForModel validates explicit wan2.7 sizes by pixel budget and ratio", () => { + assert.equal( + resolveSizeForModel("wan2.7-image-pro", { + size: "3840x2160", + aspectRatio: null, + quality: "2k", + }), + "3840*2160", + ); + + assert.throws( + () => + resolveSizeForModel("wan2.7-image-pro", { + size: "3840x2160", + aspectRatio: null, + quality: "2k", + referenceImages: ["a.png"], + }), + /total pixels between 768\*768 and 2048\*2048/, + ); + + assert.throws( + () => + resolveSizeForModel("wan2.7-image", { + size: "4096x4096", + aspectRatio: null, + quality: "2k", + }), + /total pixels between 768\*768 and 2048\*2048/, + ); + + assert.throws( + () => + resolveSizeForModel("wan2.7-image-pro", { + size: "3072*256", + aspectRatio: null, + quality: "2k", + }), + /1:8, 8:1/, + ); +}); diff --git a/skills/baoyu-imagine/scripts/providers/dashscope.ts b/skills/baoyu-imagine/scripts/providers/dashscope.ts index 6008ba6..db3b135 100644 --- a/skills/baoyu-imagine/scripts/providers/dashscope.ts +++ b/skills/baoyu-imagine/scripts/providers/dashscope.ts @@ -1,6 +1,8 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; import type { CliArgs, Quality } from "../types"; -type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy"; +type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy"; type DashScopeModelSpec = { family: DashScopeModelFamily; @@ -19,6 +21,16 @@ const QWEN_2_TARGET_PIXELS: Record = { "2k": 1536 * 1536, }; +const MIN_WAN27_TOTAL_PIXELS = 768 * 768; +const MAX_WAN27_PRO_T2I_PIXELS = 4096 * 4096; +const MAX_WAN27_GENERAL_PIXELS = 2048 * 2048; +const WAN27_MAX_REFERENCE_IMAGES = 9; + +const WAN27_TARGET_PIXELS: Record = { + normal: 1024 * 1024, + "2k": 2048 * 2048, +}; + const QWEN_2_RECOMMENDED: Record> = { "1:1": { normal: "1024*1024", "2k": "1536*1536" }, "2:3": { normal: "768*1152", "2k": "1024*1536" }, @@ -73,6 +85,11 @@ const QWEN_FIXED_SPEC: DashScopeModelSpec = { defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"], }; +const WAN27_SPEC: DashScopeModelSpec = { + family: "wan27", + defaultSize: "2048*2048", +}; + const LEGACY_SPEC: DashScopeModelSpec = { family: "legacy", defaultSize: "1536*1536", @@ -88,12 +105,31 @@ const MODEL_SPEC_ALIASES: Record = { "qwen-image-plus": QWEN_FIXED_SPEC, "qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC, "qwen-image": QWEN_FIXED_SPEC, + "wan2.7-image-pro": WAN27_SPEC, + "wan2.7-image": WAN27_SPEC, }; export function getDefaultModel(): string { return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL; } +function getReferenceImageMime(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + if (ext === ".bmp") return "image/bmp"; + return "image/png"; +} + +async function loadReferenceImage(refPath: string): Promise { + if (/^https?:\/\//i.test(refPath)) { + return refPath; + } + const fullPath = path.resolve(refPath); + const bytes = await readFile(fullPath); + return `data:${getReferenceImageMime(fullPath)};base64,${bytes.toString("base64")}`; +} + function getApiKey(): string | null { return process.env.DASHSCOPE_API_KEY || null; } @@ -173,6 +209,10 @@ function roundToStep(value: number): number { return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP); } +function floorToStep(value: number): number { + return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP); +} + function fitToPixelBudget( width: number, height: number, @@ -220,6 +260,21 @@ function fitToPixelBudget( return { width: roundedWidth, height: roundedHeight }; } +function clampWan27DerivedSizeToRatioBounds( + size: { width: number; height: number }, +): { width: number; height: number } { + let { width, height } = size; + const ratio = width / height; + + if (ratio > 8) { + width = floorToStep(height * 8); + } else if (ratio < 1 / 8) { + height = floorToStep(width * 8); + } + + return { width, height }; +} + export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string { const normalizedQuality = normalizeQuality(quality); const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES; @@ -276,6 +331,77 @@ export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs[ return formatSize(fitted.width, fitted.height); } +function isWan27ProModel(model: string): boolean { + return model.trim().toLowerCase() === "wan2.7-image-pro"; +} + +function getWan27MaxPixels(model: string, hasReferenceImages: boolean): number { + if (isWan27ProModel(model) && !hasReferenceImages) { + return MAX_WAN27_PRO_T2I_PIXELS; + } + return MAX_WAN27_GENERAL_PIXELS; +} + +export function getWan27SizeFromAspectRatio( + ar: string | null, + quality: CliArgs["quality"], + maxPixels: number, +): string { + const normalizedQuality = normalizeQuality(quality); + const targetPixels = Math.min(WAN27_TARGET_PIXELS[normalizedQuality], maxPixels); + + if (!ar) { + const side = roundToStep(Math.sqrt(targetPixels)); + return formatSize(side, side); + } + + const parsed = parseAspectRatio(ar); + if (!parsed) { + const side = roundToStep(Math.sqrt(targetPixels)); + return formatSize(side, side); + } + + const ratio = parsed.width / parsed.height; + if (ratio < 1 / 8 || ratio > 8) { + throw new Error( + `DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. Received "${ar}".` + ); + } + + const rawWidth = Math.sqrt(targetPixels * ratio); + const rawHeight = Math.sqrt(targetPixels / ratio); + const fitted = fitToPixelBudget( + rawWidth, + rawHeight, + MIN_WAN27_TOTAL_PIXELS, + maxPixels, + ); + const bounded = clampWan27DerivedSizeToRatioBounds(fitted); + + return formatSize(bounded.width, bounded.height); +} + +function validateWan27Size(size: string, maxPixels: number, model: string): string { + const normalized = normalizeSize(size); + const parsed = validateSizeFormat(normalized); + const totalPixels = parsed.width * parsed.height; + if (totalPixels < MIN_WAN27_TOTAL_PIXELS || totalPixels > maxPixels) { + const limit = maxPixels === MAX_WAN27_PRO_T2I_PIXELS ? "4096*4096" : "2048*2048"; + throw new Error( + `DashScope ${model} requires total pixels between 768*768 and ${limit} ` + + `for the current request. Received ${normalized} (${totalPixels} pixels).` + ); + } + const ratio = parsed.width / parsed.height; + if (ratio < 1 / 8 || ratio > 8) { + throw new Error( + `DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. ` + + `Received ${normalized} (ratio ${ratio.toFixed(3)}).` + ); + } + return normalized; +} + function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string { if (quality === "normal") { console.warn( @@ -331,9 +457,16 @@ function validateQwenFixedSize(size: string): string { export function resolveSizeForModel( model: string, - args: Pick, + args: Pick & { referenceImages?: string[] }, ): string { const spec = getModelSpec(model); + const referenceCount = args.referenceImages?.length ?? 0; + + if (spec.family === "wan27") { + const maxPixels = getWan27MaxPixels(model, referenceCount > 0); + if (args.size) return validateWan27Size(args.size, maxPixels, model); + return getWan27SizeFromAspectRatio(args.aspectRatio, args.quality, maxPixels); + } if (args.size) { if (spec.family === "qwen2") return validateQwen2Size(args.size); @@ -357,6 +490,14 @@ function buildParameters( family: DashScopeModelFamily, size: string, ): Record { + if (family === "wan27") { + return { + size, + n: 1, + watermark: false, + }; + } + const parameters: Record = { prompt_extend: false, size, @@ -419,23 +560,44 @@ export async function generateImage( const apiKey = getApiKey(); if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required"); - if (args.referenceImages.length > 0) { + const spec = getModelSpec(model); + + if (args.referenceImages.length > 0 && spec.family !== "wan27") { throw new Error( - "Reference images are not supported with DashScope provider in baoyu-imagine. Use --provider google with a Gemini multimodal model." + "Reference images are not supported with this DashScope model. Use a wan2.7 image model (--model wan2.7-image-pro or wan2.7-image), or switch to --provider google with a Gemini multimodal model." + ); + } + + if (args.referenceImages.length > WAN27_MAX_REFERENCE_IMAGES) { + throw new Error( + `DashScope wan2.7 image models accept at most ${WAN27_MAX_REFERENCE_IMAGES} reference images. Received ${args.referenceImages.length}.` + ); + } + + if (spec.family === "wan27" && args.n !== 1) { + throw new Error( + "DashScope wan2.7 image models in baoyu-imagine support exactly one output image per request (extra images would be billed but discarded). Remove --n or use --n 1." ); } - const spec = getModelSpec(model); const size = resolveSizeForModel(model, args); const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`; + const content: Array> = []; + if (spec.family === "wan27" && args.referenceImages.length > 0) { + for (const refPath of args.referenceImages) { + content.push({ image: await loadReferenceImage(refPath) }); + } + } + content.push({ text: prompt }); + const body = { model, input: { messages: [ { role: "user", - content: [{ text: prompt }], + content, }, ], },