feat(baoyu-imagine): add DashScope Wan 2.7 image model support (#141)

* feat(baoyu-imagine): add DashScope Wan 2.7 image model support

Closes #139.

Adds the new `wan2.7-image-pro` and `wan2.7-image` model family to the
DashScope provider so users can call Wan 2.7 directly through the
official Aliyun (Bailian) API instead of going through Replicate.

- Register `wan2.7-image-pro` and `wan2.7-image` as a new `wan27` family
  in the DashScope provider with their own size resolution rules:
  pixel range `[768*768, 4096*4096]` for `wan2.7-image-pro` text-to-image,
  `[768*768, 2048*2048]` for `wan2.7-image-pro` with refs and for the
  base `wan2.7-image` model in any mode, with aspect ratios validated
  against the documented `[1:8, 8:1]` band.
- Allow up to 9 reference images per request (image editing /
  multi-image fusion). Local files are inlined as base64 data URLs;
  `http(s)://` paths are forwarded as-is. Other DashScope models still
  reject `--ref` with a hint to switch to a wan2.7 model or another
  provider.
- Drop `prompt_extend` from the request body for the Wan 2.7 family
  (not part of the Wan 2.7 API surface) and skip the Qwen-only negative
  prompt for this family.
- Allow `--provider dashscope --ref ...` in `detectProvider` so users
  can opt into Wan 2.7 reference workflows, while keeping Wan 2.7 out
  of the auto-detect ref priority list.
- Add provider, reference, and usage-example documentation, plus
  unit tests covering family routing, size derivation across the
  three pixel-budget modes, ratio rejection, explicit-size validation,
  and the new `--provider dashscope` ref opt-in path.

Made-with: Cursor

* fix(baoyu-imagine): force n=1 for DashScope wan2.7 to avoid silent multi-image billing

Cross-checked the implementation against the official Wan 2.7 image
generation & editing API reference and found that the API defaults
`parameters.n` to 4 in non-collage mode (1-4 range, billed per image).
baoyu-imagine has single-image save semantics — only the first image
in the response is kept — so without an explicit `n: 1` users would
silently pay for 3 discarded images per request.

- Always send `parameters.n: 1` in the wan2.7 request body
- Reject `--n > 1` for wan2.7 with a clear error pointing at the
  single-image save semantics
- Add tests asserting the request body shape (n=1, no prompt_extend,
  no negative_prompt) and the --n>1 rejection
- Document the defaults-vs-skill mismatch in the dashscope reference

Made-with: Cursor

* Fix DashScope Wan 2.7 review feedback
This commit is contained in:
Jim Liu 宝玉
2026-04-25 14:54:08 -05:00
committed by GitHub
parent 31d728b505
commit 6d063734ae
8 changed files with 551 additions and 19 deletions
+1 -1
View File
@@ -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 <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0 |
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, Seedream 5.0/4.5/4.0, DashScope `wan2.7-image-pro`/`wan2.7-image`. Not supported by Jimeng, Seedream 3.0, SeedEdit 3.0, or any DashScope model outside the `wan2.7-image*` family |
| `--n <count>` | Number of images. Replicate requires `--n 1` (single-output save semantics) |
| `--json` | JSON output |
@@ -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
@@ -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)
@@ -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
+64 -2
View File
@@ -19,6 +19,7 @@ import {
parseArgs,
parseOpenAIImageApiDialect,
parseSimpleYaml,
validateReferenceImages,
} from "./main.ts";
function makeArgs(overrides: Partial<CliArgs> = {}): 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);
});
+41 -7
View File
@@ -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 <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, or Seedream 4.0/4.5/5.0)
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
--json JSON output
-h, --help Show help
@@ -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<void> {
export type ReferenceImageValidationOptions = {
allowRemoteUrls?: boolean;
};
function isRemoteReferenceImage(refPath: string): boolean {
return /^https?:\/\//i.test(refPath);
}
function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {
return provider === "dashscope";
}
export async function validateReferenceImages(
referenceImages: string[],
options: ReferenceImageValidationOptions = {},
): Promise<void> {
for (const refPath of referenceImages) {
if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue;
const fullPath = path.resolve(refPath);
try {
await access(fullPath);
@@ -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<ExtendConf
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
if (!prompt) throw new Error("Prompt is required");
if (!args.imagePath) throw new Error("--image is required");
if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
if (args.referenceImages.length > 0) {
await validateReferenceImages(args.referenceImages, {
allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider),
});
}
const provider = detectProvider(args);
const providerModule = await loadProviderModule(provider);
@@ -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);
@@ -2,15 +2,42 @@ import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import {
generateImage,
getDefaultModel,
getModelFamily,
getQwen2SizeFromAspectRatio,
getSizeFromAspectRatio,
getWan27SizeFromAspectRatio,
normalizeSize,
parseAspectRatio,
parseSize,
resolveSizeForModel,
} from "./dashscope.ts";
import type { CliArgs } from "../types.ts";
function makeCliArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: "dashscope",
model: null,
aspectRatio: null,
aspectRatioSource: null,
size: null,
quality: "2k",
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
function useEnv(
t: TestContext,
@@ -51,9 +78,11 @@ test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
assert.equal(parseAspectRatio("-1:2"), null);
});
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, wan2.7, and legacy models", () => {
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
assert.equal(getModelFamily("wan2.7-image"), "wan27");
assert.equal(getModelFamily("wan2.7-image-pro"), "wan27");
assert.equal(getModelFamily("z-image-turbo"), "legacy");
assert.equal(getModelFamily("wanx-v1"), "legacy");
});
@@ -146,3 +175,218 @@ test("DashScope size normalization converts WxH into provider format", () => {
assert.equal(normalizeSize("1024x1024"), "1024*1024");
assert.equal(normalizeSize("2048*1152"), "2048*1152");
});
test("Wan 2.7 derives sizes that match the requested ratio at the chosen pixel budget", () => {
const square2k = getWan27SizeFromAspectRatio(null, "2k", 2048 * 2048);
const parsedSquare = parseSize(square2k);
assert.ok(parsedSquare);
assert.equal(parsedSquare.width, parsedSquare.height);
assert.ok(parsedSquare.width * parsedSquare.height <= 2048 * 2048);
const widescreen = getWan27SizeFromAspectRatio("16:9", "2k", 2048 * 2048);
const parsedWide = parseSize(widescreen);
assert.ok(parsedWide);
assert.ok(Math.abs(parsedWide.width / parsedWide.height - 16 / 9) < 0.05);
assert.ok(parsedWide.width * parsedWide.height <= 2048 * 2048);
const pro4k = getWan27SizeFromAspectRatio("16:9", "2k", 4096 * 4096);
const parsed4k = parseSize(pro4k);
assert.ok(parsed4k);
assert.ok(parsed4k.width * parsed4k.height > 2048 * 2048);
assert.ok(parsed4k.width * parsed4k.height <= 4096 * 4096);
});
test("Wan 2.7 rejects aspect ratios outside the [1:8, 8:1] range", () => {
assert.throws(
() => getWan27SizeFromAspectRatio("9:1", "2k", 2048 * 2048),
/1:8, 8:1/,
);
assert.throws(
() => getWan27SizeFromAspectRatio("1:9", "normal", 2048 * 2048),
/1:8, 8:1/,
);
});
test("Wan 2.7 derived sizes stay inside the boundary ratio limits after rounding", () => {
for (const ar of ["8:1", "1:8"]) {
const size = getWan27SizeFromAspectRatio(ar, "2k", 2048 * 2048);
const parsed = parseSize(size);
assert.ok(parsed);
const ratio = parsed.width / parsed.height;
assert.ok(ratio >= 1 / 8);
assert.ok(ratio <= 8);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
}
});
test("resolveSizeForModel routes wan2.7-image to the 2K-capped derivation", () => {
const size = resolveSizeForModel("wan2.7-image", {
size: null,
aspectRatio: "16:9",
quality: "2k",
});
const parsed = parseSize(size);
assert.ok(parsed);
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
assert.ok(Math.abs(parsed.width / parsed.height - 16 / 9) < 0.05);
});
test("resolveSizeForModel allows wan2.7-image-pro 4K only when there are no reference images", () => {
assert.equal(
resolveSizeForModel("wan2.7-image-pro", {
size: "4096*4096",
aspectRatio: null,
quality: "2k",
}),
"4096*4096",
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "4096*4096",
aspectRatio: null,
quality: "2k",
referenceImages: ["a.png"],
}),
/total pixels between 768\*768 and 2048\*2048/,
);
const proWithRef = resolveSizeForModel("wan2.7-image-pro", {
size: null,
aspectRatio: "1:1",
quality: "2k",
referenceImages: ["a.png"],
});
const parsedRef = parseSize(proWithRef);
assert.ok(parsedRef);
assert.ok(parsedRef.width * parsedRef.height <= 2048 * 2048);
});
test("Wan 2.7 request body forces n=1 and omits prompt_extend / negative_prompt", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
const originalFetch = globalThis.fetch;
let capturedBody: any = null;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
output: {
choices: [
{
message: {
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
},
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
await generateImage("hello", "wan2.7-image-pro", makeCliArgs({ aspectRatio: "1:1" }));
assert.equal(capturedBody.model, "wan2.7-image-pro");
assert.deepEqual(Object.keys(capturedBody.parameters).sort(), ["n", "size", "watermark"]);
assert.equal(capturedBody.parameters.n, 1);
assert.equal(capturedBody.parameters.watermark, false);
assert.equal(typeof capturedBody.parameters.size, "string");
assert.ok(!("prompt_extend" in capturedBody.parameters));
assert.ok(!("negative_prompt" in capturedBody.parameters));
assert.deepEqual(capturedBody.input.messages[0].content, [{ text: "hello" }]);
});
test("Wan 2.7 request body forwards remote reference image URLs", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
const originalFetch = globalThis.fetch;
let capturedBody: any = null;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
output: {
choices: [
{
message: {
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
},
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
await generateImage(
"combine these",
"wan2.7-image-pro",
makeCliArgs({ referenceImages: ["https://example.com/ref.png"] }),
);
assert.deepEqual(capturedBody.input.messages[0].content, [
{ image: "https://example.com/ref.png" },
{ text: "combine these" },
]);
});
test("Wan 2.7 rejects --n > 1 to prevent silent multi-image billing", async (t) => {
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
await assert.rejects(
() => generateImage("hi", "wan2.7-image-pro", makeCliArgs({ n: 2 })),
/support exactly one output image/,
);
});
test("resolveSizeForModel validates explicit wan2.7 sizes by pixel budget and ratio", () => {
assert.equal(
resolveSizeForModel("wan2.7-image-pro", {
size: "3840x2160",
aspectRatio: null,
quality: "2k",
}),
"3840*2160",
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "3840x2160",
aspectRatio: null,
quality: "2k",
referenceImages: ["a.png"],
}),
/total pixels between 768\*768 and 2048\*2048/,
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image", {
size: "4096x4096",
aspectRatio: null,
quality: "2k",
}),
/total pixels between 768\*768 and 2048\*2048/,
);
assert.throws(
() =>
resolveSizeForModel("wan2.7-image-pro", {
size: "3072*256",
aspectRatio: null,
quality: "2k",
}),
/1:8, 8:1/,
);
});
@@ -1,6 +1,8 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs, Quality } from "../types";
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy";
type DashScopeModelSpec = {
family: DashScopeModelFamily;
@@ -19,6 +21,16 @@ const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
"2k": 1536 * 1536,
};
const MIN_WAN27_TOTAL_PIXELS = 768 * 768;
const MAX_WAN27_PRO_T2I_PIXELS = 4096 * 4096;
const MAX_WAN27_GENERAL_PIXELS = 2048 * 2048;
const WAN27_MAX_REFERENCE_IMAGES = 9;
const WAN27_TARGET_PIXELS: Record<Quality, number> = {
normal: 1024 * 1024,
"2k": 2048 * 2048,
};
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
"2:3": { normal: "768*1152", "2k": "1024*1536" },
@@ -73,6 +85,11 @@ const QWEN_FIXED_SPEC: DashScopeModelSpec = {
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
};
const WAN27_SPEC: DashScopeModelSpec = {
family: "wan27",
defaultSize: "2048*2048",
};
const LEGACY_SPEC: DashScopeModelSpec = {
family: "legacy",
defaultSize: "1536*1536",
@@ -88,12 +105,31 @@ const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
"qwen-image-plus": QWEN_FIXED_SPEC,
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
"qwen-image": QWEN_FIXED_SPEC,
"wan2.7-image-pro": WAN27_SPEC,
"wan2.7-image": WAN27_SPEC,
};
export function getDefaultModel(): string {
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
}
function getReferenceImageMime(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".bmp") return "image/bmp";
return "image/png";
}
async function loadReferenceImage(refPath: string): Promise<string> {
if (/^https?:\/\//i.test(refPath)) {
return refPath;
}
const fullPath = path.resolve(refPath);
const bytes = await readFile(fullPath);
return `data:${getReferenceImageMime(fullPath)};base64,${bytes.toString("base64")}`;
}
function getApiKey(): string | null {
return process.env.DASHSCOPE_API_KEY || null;
}
@@ -173,6 +209,10 @@ function roundToStep(value: number): number {
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
}
function floorToStep(value: number): number {
return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP);
}
function fitToPixelBudget(
width: number,
height: number,
@@ -220,6 +260,21 @@ function fitToPixelBudget(
return { width: roundedWidth, height: roundedHeight };
}
function clampWan27DerivedSizeToRatioBounds(
size: { width: number; height: number },
): { width: number; height: number } {
let { width, height } = size;
const ratio = width / height;
if (ratio > 8) {
width = floorToStep(height * 8);
} else if (ratio < 1 / 8) {
height = floorToStep(width * 8);
}
return { width, height };
}
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
const normalizedQuality = normalizeQuality(quality);
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
@@ -276,6 +331,77 @@ export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs[
return formatSize(fitted.width, fitted.height);
}
function isWan27ProModel(model: string): boolean {
return model.trim().toLowerCase() === "wan2.7-image-pro";
}
function getWan27MaxPixels(model: string, hasReferenceImages: boolean): number {
if (isWan27ProModel(model) && !hasReferenceImages) {
return MAX_WAN27_PRO_T2I_PIXELS;
}
return MAX_WAN27_GENERAL_PIXELS;
}
export function getWan27SizeFromAspectRatio(
ar: string | null,
quality: CliArgs["quality"],
maxPixels: number,
): string {
const normalizedQuality = normalizeQuality(quality);
const targetPixels = Math.min(WAN27_TARGET_PIXELS[normalizedQuality], maxPixels);
if (!ar) {
const side = roundToStep(Math.sqrt(targetPixels));
return formatSize(side, side);
}
const parsed = parseAspectRatio(ar);
if (!parsed) {
const side = roundToStep(Math.sqrt(targetPixels));
return formatSize(side, side);
}
const ratio = parsed.width / parsed.height;
if (ratio < 1 / 8 || ratio > 8) {
throw new Error(
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. Received "${ar}".`
);
}
const rawWidth = Math.sqrt(targetPixels * ratio);
const rawHeight = Math.sqrt(targetPixels / ratio);
const fitted = fitToPixelBudget(
rawWidth,
rawHeight,
MIN_WAN27_TOTAL_PIXELS,
maxPixels,
);
const bounded = clampWan27DerivedSizeToRatioBounds(fitted);
return formatSize(bounded.width, bounded.height);
}
function validateWan27Size(size: string, maxPixels: number, model: string): string {
const normalized = normalizeSize(size);
const parsed = validateSizeFormat(normalized);
const totalPixels = parsed.width * parsed.height;
if (totalPixels < MIN_WAN27_TOTAL_PIXELS || totalPixels > maxPixels) {
const limit = maxPixels === MAX_WAN27_PRO_T2I_PIXELS ? "4096*4096" : "2048*2048";
throw new Error(
`DashScope ${model} requires total pixels between 768*768 and ${limit} ` +
`for the current request. Received ${normalized} (${totalPixels} pixels).`
);
}
const ratio = parsed.width / parsed.height;
if (ratio < 1 / 8 || ratio > 8) {
throw new Error(
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. ` +
`Received ${normalized} (ratio ${ratio.toFixed(3)}).`
);
}
return normalized;
}
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
if (quality === "normal") {
console.warn(
@@ -331,9 +457,16 @@ function validateQwenFixedSize(size: string): string {
export function resolveSizeForModel(
model: string,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
args: Pick<CliArgs, "size" | "aspectRatio" | "quality"> & { referenceImages?: string[] },
): string {
const spec = getModelSpec(model);
const referenceCount = args.referenceImages?.length ?? 0;
if (spec.family === "wan27") {
const maxPixels = getWan27MaxPixels(model, referenceCount > 0);
if (args.size) return validateWan27Size(args.size, maxPixels, model);
return getWan27SizeFromAspectRatio(args.aspectRatio, args.quality, maxPixels);
}
if (args.size) {
if (spec.family === "qwen2") return validateQwen2Size(args.size);
@@ -357,6 +490,14 @@ function buildParameters(
family: DashScopeModelFamily,
size: string,
): Record<string, unknown> {
if (family === "wan27") {
return {
size,
n: 1,
watermark: false,
};
}
const parameters: Record<string, unknown> = {
prompt_extend: false,
size,
@@ -419,23 +560,44 @@ export async function generateImage(
const apiKey = getApiKey();
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
if (args.referenceImages.length > 0) {
const spec = getModelSpec(model);
if (args.referenceImages.length > 0 && spec.family !== "wan27") {
throw new Error(
"Reference images are not supported with DashScope provider in baoyu-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<Record<string, unknown>> = [];
if (spec.family === "wan27" && args.referenceImages.length > 0) {
for (const refPath of args.referenceImages) {
content.push({ image: await loadReferenceImage(refPath) });
}
}
content.push({ text: prompt });
const body = {
model,
input: {
messages: [
{
role: "user",
content: [{ text: prompt }],
content,
},
],
},