mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
6d063734ae
* 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
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test, { type TestContext } from "node:test";
|
|
|
|
import {
|
|
generateImage,
|
|
getDefaultModel,
|
|
getModelFamily,
|
|
getQwen2SizeFromAspectRatio,
|
|
getSizeFromAspectRatio,
|
|
getWan27SizeFromAspectRatio,
|
|
normalizeSize,
|
|
parseAspectRatio,
|
|
parseSize,
|
|
resolveSizeForModel,
|
|
} from "./dashscope.ts";
|
|
import type { CliArgs } from "../types.ts";
|
|
|
|
function makeCliArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|
return {
|
|
prompt: null,
|
|
promptFiles: [],
|
|
imagePath: null,
|
|
provider: "dashscope",
|
|
model: null,
|
|
aspectRatio: null,
|
|
aspectRatioSource: null,
|
|
size: null,
|
|
quality: "2k",
|
|
imageSize: null,
|
|
imageSizeSource: null,
|
|
imageApiDialect: null,
|
|
referenceImages: [],
|
|
n: 1,
|
|
batchFile: null,
|
|
jobs: null,
|
|
json: false,
|
|
help: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function useEnv(
|
|
t: TestContext,
|
|
values: Record<string, string | null>,
|
|
): void {
|
|
const previous = new Map<string, string | undefined>();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
previous.set(key, process.env[key]);
|
|
if (value == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
t.after(() => {
|
|
for (const [key, value] of previous.entries()) {
|
|
if (value == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
|
|
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
|
|
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
|
|
|
|
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
|
|
assert.equal(getDefaultModel(), "qwen-image-max");
|
|
});
|
|
|
|
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
|
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
|
assert.equal(parseAspectRatio("square"), null);
|
|
assert.equal(parseAspectRatio("-1:2"), null);
|
|
});
|
|
|
|
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, wan2.7, and legacy models", () => {
|
|
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
|
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
|
assert.equal(getModelFamily("wan2.7-image"), "wan27");
|
|
assert.equal(getModelFamily("wan2.7-image-pro"), "wan27");
|
|
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
|
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
|
});
|
|
|
|
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
|
|
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
|
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
|
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
|
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
|
});
|
|
|
|
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
|
|
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
|
|
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
|
|
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
|
|
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
|
|
});
|
|
|
|
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
|
|
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
|
|
const parsed = parseSize(size);
|
|
assert.ok(parsed);
|
|
assert.ok(parsed.width * parsed.height >= 512 * 512);
|
|
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
|
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
|
|
});
|
|
|
|
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
|
|
assert.equal(
|
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
|
size: "2048x872",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
"2048*872",
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("qwen-image-2.0-pro", {
|
|
size: "4096x4096",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
/total pixels between/,
|
|
);
|
|
});
|
|
|
|
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
|
|
assert.equal(
|
|
resolveSizeForModel("qwen-image-max", {
|
|
size: null,
|
|
aspectRatio: "1:1",
|
|
quality: "2k",
|
|
}),
|
|
"1328*1328",
|
|
);
|
|
|
|
assert.equal(
|
|
resolveSizeForModel("qwen-image", {
|
|
size: "1664x928",
|
|
aspectRatio: "9:16",
|
|
quality: "normal",
|
|
}),
|
|
"1664*928",
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("qwen-image-max", {
|
|
size: null,
|
|
aspectRatio: "21:9",
|
|
quality: "2k",
|
|
}),
|
|
/supports only fixed ratios/,
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("qwen-image-plus", {
|
|
size: "1024x1024",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
/support only these sizes/,
|
|
);
|
|
});
|
|
|
|
test("DashScope size normalization converts WxH into provider format", () => {
|
|
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
|
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
|
});
|
|
|
|
test("Wan 2.7 derives sizes that match the requested ratio at the chosen pixel budget", () => {
|
|
const square2k = getWan27SizeFromAspectRatio(null, "2k", 2048 * 2048);
|
|
const parsedSquare = parseSize(square2k);
|
|
assert.ok(parsedSquare);
|
|
assert.equal(parsedSquare.width, parsedSquare.height);
|
|
assert.ok(parsedSquare.width * parsedSquare.height <= 2048 * 2048);
|
|
|
|
const widescreen = getWan27SizeFromAspectRatio("16:9", "2k", 2048 * 2048);
|
|
const parsedWide = parseSize(widescreen);
|
|
assert.ok(parsedWide);
|
|
assert.ok(Math.abs(parsedWide.width / parsedWide.height - 16 / 9) < 0.05);
|
|
assert.ok(parsedWide.width * parsedWide.height <= 2048 * 2048);
|
|
|
|
const pro4k = getWan27SizeFromAspectRatio("16:9", "2k", 4096 * 4096);
|
|
const parsed4k = parseSize(pro4k);
|
|
assert.ok(parsed4k);
|
|
assert.ok(parsed4k.width * parsed4k.height > 2048 * 2048);
|
|
assert.ok(parsed4k.width * parsed4k.height <= 4096 * 4096);
|
|
});
|
|
|
|
test("Wan 2.7 rejects aspect ratios outside the [1:8, 8:1] range", () => {
|
|
assert.throws(
|
|
() => getWan27SizeFromAspectRatio("9:1", "2k", 2048 * 2048),
|
|
/1:8, 8:1/,
|
|
);
|
|
assert.throws(
|
|
() => getWan27SizeFromAspectRatio("1:9", "normal", 2048 * 2048),
|
|
/1:8, 8:1/,
|
|
);
|
|
});
|
|
|
|
test("Wan 2.7 derived sizes stay inside the boundary ratio limits after rounding", () => {
|
|
for (const ar of ["8:1", "1:8"]) {
|
|
const size = getWan27SizeFromAspectRatio(ar, "2k", 2048 * 2048);
|
|
const parsed = parseSize(size);
|
|
assert.ok(parsed);
|
|
const ratio = parsed.width / parsed.height;
|
|
assert.ok(ratio >= 1 / 8);
|
|
assert.ok(ratio <= 8);
|
|
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
|
}
|
|
});
|
|
|
|
test("resolveSizeForModel routes wan2.7-image to the 2K-capped derivation", () => {
|
|
const size = resolveSizeForModel("wan2.7-image", {
|
|
size: null,
|
|
aspectRatio: "16:9",
|
|
quality: "2k",
|
|
});
|
|
const parsed = parseSize(size);
|
|
assert.ok(parsed);
|
|
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
|
assert.ok(Math.abs(parsed.width / parsed.height - 16 / 9) < 0.05);
|
|
});
|
|
|
|
test("resolveSizeForModel allows wan2.7-image-pro 4K only when there are no reference images", () => {
|
|
assert.equal(
|
|
resolveSizeForModel("wan2.7-image-pro", {
|
|
size: "4096*4096",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
"4096*4096",
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("wan2.7-image-pro", {
|
|
size: "4096*4096",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
referenceImages: ["a.png"],
|
|
}),
|
|
/total pixels between 768\*768 and 2048\*2048/,
|
|
);
|
|
|
|
const proWithRef = resolveSizeForModel("wan2.7-image-pro", {
|
|
size: null,
|
|
aspectRatio: "1:1",
|
|
quality: "2k",
|
|
referenceImages: ["a.png"],
|
|
});
|
|
const parsedRef = parseSize(proWithRef);
|
|
assert.ok(parsedRef);
|
|
assert.ok(parsedRef.width * parsedRef.height <= 2048 * 2048);
|
|
});
|
|
|
|
test("Wan 2.7 request body forces n=1 and omits prompt_extend / negative_prompt", async (t) => {
|
|
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
let capturedBody: any = null;
|
|
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
|
capturedBody = JSON.parse(String(init?.body));
|
|
return new Response(
|
|
JSON.stringify({
|
|
output: {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}) as typeof fetch;
|
|
t.after(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
await generateImage("hello", "wan2.7-image-pro", makeCliArgs({ aspectRatio: "1:1" }));
|
|
|
|
assert.equal(capturedBody.model, "wan2.7-image-pro");
|
|
assert.deepEqual(Object.keys(capturedBody.parameters).sort(), ["n", "size", "watermark"]);
|
|
assert.equal(capturedBody.parameters.n, 1);
|
|
assert.equal(capturedBody.parameters.watermark, false);
|
|
assert.equal(typeof capturedBody.parameters.size, "string");
|
|
assert.ok(!("prompt_extend" in capturedBody.parameters));
|
|
assert.ok(!("negative_prompt" in capturedBody.parameters));
|
|
|
|
assert.deepEqual(capturedBody.input.messages[0].content, [{ text: "hello" }]);
|
|
});
|
|
|
|
test("Wan 2.7 request body forwards remote reference image URLs", async (t) => {
|
|
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
let capturedBody: any = null;
|
|
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
|
capturedBody = JSON.parse(String(init?.body));
|
|
return new Response(
|
|
JSON.stringify({
|
|
output: {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}) as typeof fetch;
|
|
t.after(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
await generateImage(
|
|
"combine these",
|
|
"wan2.7-image-pro",
|
|
makeCliArgs({ referenceImages: ["https://example.com/ref.png"] }),
|
|
);
|
|
|
|
assert.deepEqual(capturedBody.input.messages[0].content, [
|
|
{ image: "https://example.com/ref.png" },
|
|
{ text: "combine these" },
|
|
]);
|
|
});
|
|
|
|
test("Wan 2.7 rejects --n > 1 to prevent silent multi-image billing", async (t) => {
|
|
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
|
|
|
await assert.rejects(
|
|
() => generateImage("hi", "wan2.7-image-pro", makeCliArgs({ n: 2 })),
|
|
/support exactly one output image/,
|
|
);
|
|
});
|
|
|
|
test("resolveSizeForModel validates explicit wan2.7 sizes by pixel budget and ratio", () => {
|
|
assert.equal(
|
|
resolveSizeForModel("wan2.7-image-pro", {
|
|
size: "3840x2160",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
"3840*2160",
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("wan2.7-image-pro", {
|
|
size: "3840x2160",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
referenceImages: ["a.png"],
|
|
}),
|
|
/total pixels between 768\*768 and 2048\*2048/,
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("wan2.7-image", {
|
|
size: "4096x4096",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
/total pixels between 768\*768 and 2048\*2048/,
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
resolveSizeForModel("wan2.7-image-pro", {
|
|
size: "3072*256",
|
|
aspectRatio: null,
|
|
quality: "2k",
|
|
}),
|
|
/1:8, 8:1/,
|
|
);
|
|
});
|