From 9a1370039081f151da01346d08365c354c733485 Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 27 Jul 2026 10:16:25 +0800 Subject: [PATCH 1/3] fix(image): route text-to-image and image-edit by model family Fix wanx/wan2.x-t2i, wan2.5-i2i, z-image, and qwen-image-plus hitting the wrong endpoint, and add routing unit tests plus dry-run coverage. --- packages/commands/src/commands/image/edit.ts | 150 ++++++++------ .../commands/src/commands/image/generate.ts | 108 +++++----- .../tests/e2e/image-generate.e2e.test.ts | 72 +++++++ packages/core/src/client/endpoints.ts | 13 +- packages/core/src/client/image-routes.ts | 125 ++++++++++++ packages/core/src/client/index.ts | 11 + packages/core/src/types/api.ts | 19 +- packages/core/tests/image-routes.test.ts | 101 +++++++++ packages/runtime/src/pipeline/steps/bl-api.ts | 191 ++++++++++-------- skills/bailian-cli/reference/image.md | 12 ++ 10 files changed, 600 insertions(+), 202 deletions(-) create mode 100644 packages/core/src/client/image-routes.ts create mode 100644 packages/core/tests/image-routes.test.ts diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index a6996e40..c4a1d08f 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -1,7 +1,5 @@ import { defineCommand, - imagePath, - imageSyncPath, taskPath, detectOutputFormat, resolveOutputDir, @@ -20,6 +18,7 @@ import { BailianError, resolveBooleanFlag, resolveWatermark, + resolveImageEditApi, ASYNC_FLAG, CONCURRENT_FLAG, redactDataUri, @@ -32,13 +31,8 @@ import { resolveImageSize } from "bailian-cli-runtime"; import { join } from "path"; import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"]; const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; -function isSyncModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); -} - function enablesPromptExtendByDefault(model: string): boolean { return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); } @@ -114,6 +108,7 @@ export default defineCommand({ '--image ./a.png --image ./b.png --prompt "Merge two images into one collage"', '--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro', '--image ./photo.png --prompt "Change the style" --model wan2.7-image', + '--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview', '--image ./photo.png --prompt "Replace the background with a beach" --watermark false', ], async run(ctx) { @@ -128,7 +123,7 @@ export default defineCommand({ const prompt = flags.prompt; const model = flags.model || settings.defaultImageModel || "qwen-image-2.0"; - const useSync = isSyncModel(model); + const route = resolveImageEditApi(model); // Auto-upload local files (resolve all images in parallel) const resolvedImages = await Promise.all( @@ -142,67 +137,94 @@ export default defineCommand({ "prompt-extend", ); - // Build content: all images first, then text prompt - const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map( - (u: string) => ({ image: u }), - ); - contentItems.push({ text: prompt }); - const watermark = resolveWatermark(flags.watermark); - const body: DashScopeImageRequest = { - model, - input: { - messages: [ - { - role: "user", - content: contentItems, - }, - ], - }, - parameters: { - size: resolveImageSize(flags.size, useSync), - n, - seed: flags.seed, - prompt_extend: promptExtend, - watermark, - negative_prompt: flags.negativePrompt || undefined, - }, + const parameters: NonNullable = { + size: resolveImageSize(flags.size, route.useSync), + n, + seed: flags.seed, + prompt_extend: promptExtend, + watermark, }; + let body: DashScopeImageRequest; + if (route.inputStyle === "prompt-images") { + body = { + model, + input: { + prompt, + images: resolvedImages, + negative_prompt: flags.negativePrompt || undefined, + }, + parameters, + }; + } else { + const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map( + (imageUrl: string) => ({ image: imageUrl }), + ); + contentItems.push({ text: prompt }); + body = { + model, + input: { + messages: [ + { + role: "user", + content: contentItems, + }, + ], + }, + parameters: { + ...parameters, + negative_prompt: flags.negativePrompt || undefined, + }, + }; + } + // Remove undefined parameters stripUndefined(body.parameters as Record); const format = detectOutputFormat(settings.output); if (settings.dryRun) { - const previewBody = { - ...body, - input: { - messages: body.input.messages.map((message) => ({ - ...message, - content: message.content.map((item) => - item.image ? { ...item, image: redactDataUri(item.image) } : item, - ), - })), - }, - }; - emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format); + const previewBody = + "messages" in body.input + ? { + ...body, + input: { + messages: body.input.messages.map((message) => ({ + ...message, + content: message.content.map((item) => + item.image ? { ...item, image: redactDataUri(item.image) } : item, + ), + })), + }, + } + : { + ...body, + input: { + ...body.input, + images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)), + }, + }; + emitResult( + { request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path }, + format, + ); return; } if (!settings.quiet) { process.stderr.write( - `[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`, + `[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`, ); } const concurrent = getConcurrency(flags); - if (useSync) { - await handleSyncMode(ctx.client, settings, body, flags, format, concurrent); + if (route.useSync) { + await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } else { - await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent); + await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } }, }); @@ -210,6 +232,7 @@ export default defineCommand({ async function handleSyncMode( client: Client, settings: Settings, + path: string, body: DashScopeImageRequest, flags: EditFlags, format: OutputFormat, @@ -217,15 +240,15 @@ async function handleSyncMode( ): Promise { const results = await runConcurrent(concurrent, settings, () => client.requestJson({ - path: imageSyncPath(), + path, method: "POST", body, }), ); const imageUrls = results - .flatMap((r) => r.output.choices || []) - .flatMap((c) => c.message?.content || []) + .flatMap((result) => result.output.choices || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); @@ -239,6 +262,7 @@ async function handleSyncMode( async function handleAsyncMode( client: Client, settings: Settings, + path: string, body: DashScopeImageRequest, flags: EditFlags, format: OutputFormat, @@ -249,14 +273,14 @@ async function handleAsyncMode( settings, () => client.requestJson({ - path: imagePath(), + path, method: "POST", body, async: true, }), "tasks", ); - const taskIds = responses.map((r) => r.output.task_id); + const taskIds = responses.map((response) => response.output.task_id); if (flags.async) { emitResult({ task_ids: taskIds }, format); @@ -269,12 +293,12 @@ async function handleAsyncMode( url: client.url(taskPath(taskId)), intervalSec: pollInterval, timeoutSec: settings.timeout, - isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", - isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", - getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, - getErrorMessage: (d) => { - const o = (d as DashScopeTaskResponse).output; - return o.message || o.code || undefined; + isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (data) => (data as DashScopeTaskResponse).output.task_status, + getErrorMessage: (data) => { + const output = (data as DashScopeTaskResponse).output; + return output.message || output.code || undefined; }, }), ); @@ -285,13 +309,13 @@ async function handleAsyncMode( for (const result of results) { if (result.output.choices) { const urls = result.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); imageUrls.push(...urls); } if (result.output.results) { - const urls = result.output.results.map((r) => r.url).filter(Boolean); + const urls = result.output.results.map((item) => item.url).filter(Boolean); if (urls.length > 0 && imageUrls.length === 0) { imageUrls.push(...urls); } @@ -321,8 +345,8 @@ async function saveImages( // Parallel download all images const items = imageUrls.length > 1 - ? imageUrls.map((url, i) => { - const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`; + ? imageUrls.map((url, index) => { + const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`; return { url, destPath: join(outDir, filename) }; }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 00bd7f5f..6a7db1b6 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -1,7 +1,5 @@ import { defineCommand, - imagePath, - imageSyncPath, taskPath, detectOutputFormat, type Client, @@ -19,6 +17,7 @@ import { generateFilename, resolveBooleanFlag, resolveWatermark, + resolveImageGenerateApi, ASYNC_FLAG, CONCURRENT_FLAG, } from "bailian-cli-core"; @@ -31,14 +30,8 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai import { join } from "path"; -// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint. -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"]; const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; -function isSyncModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); -} - function enablesPromptExtendByDefault(model: string): boolean { return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); } @@ -109,6 +102,8 @@ export default defineCommand({ '--prompt "Logo" --watermark false', '--prompt "An alien in the space" --watermark false', '--prompt "sunset" --model wan2.6-t2i --async --quiet', + '--prompt "plush doll" --model z-image-turbo --size 1024*1024', + '--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024', '--prompt "Pro quality" --model qwen-image-2.0-pro', '--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel', ], @@ -117,10 +112,10 @@ export default defineCommand({ const prompt = flags.prompt; const model = flags.model || settings.defaultImageModel || "qwen-image-2.0"; - const useSync = isSyncModel(model); - const defaultSize = useSync ? "1:1" : "1:1"; + const route = resolveImageGenerateApi(model); + const defaultSize = "1:1"; const sizeInput = flags.size || defaultSize; - const size = resolveImageSize(sizeInput, useSync); + const size = resolveImageSize(sizeInput, route.useSync); const n = flags.n ?? 1; const concurrent = getConcurrency(flags); @@ -132,58 +127,75 @@ export default defineCommand({ const watermark = resolveWatermark(flags.watermark); - const body: DashScopeImageRequest = { - model, - input: { - messages: [{ role: "user", content: [{ text: prompt }] }], - }, - parameters: { - size, - n, - seed: flags.seed, - prompt_extend: promptExtend, - watermark, - negative_prompt: flags.negativePrompt || undefined, - }, + const parameters: NonNullable = { + size, + n, + seed: flags.seed, + prompt_extend: promptExtend, + watermark, }; + const body: DashScopeImageRequest = + route.inputStyle === "prompt" + ? { + model, + input: { + prompt, + negative_prompt: flags.negativePrompt || undefined, + }, + parameters, + } + : { + model, + input: { + messages: [{ role: "user", content: [{ text: prompt }] }], + }, + parameters: { + ...parameters, + negative_prompt: flags.negativePrompt || undefined, + }, + }; + const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult({ request: body, mode: useSync ? "sync" : "async" }, format); + emitResult( + { request: body, mode: route.useSync ? "sync" : "async", path: route.path }, + format, + ); return; } if (!settings.quiet) { - process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`); + process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`); } - if (useSync) { - await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent); + if (route.useSync) { + await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } else { - await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent); + await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent); } }, }); -// ---- Sync mode: qwen-image-2.0 series ---- +// ---- Sync mode: qwen-image / wan2.7-image / z-image ---- async function handleSyncMode( client: Client, settings: Settings, - _model: string, + path: string, body: DashScopeImageRequest, flags: GenerateFlags, format: string, concurrent: number, ): Promise { const results = await runConcurrent(concurrent, settings, () => - client.requestJson({ path: imageSyncPath(), method: "POST", body }), + client.requestJson({ path, method: "POST", body }), ); const imageUrls = results - .flatMap((r) => r.output.choices || []) - .flatMap((c) => c.message?.content || []) + .flatMap((result) => result.output.choices || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); @@ -194,12 +206,12 @@ async function handleSyncMode( await saveImages(imageUrls, flags, settings, format); } -// ---- Async mode: wan2.x / qwen-image-plus ---- +// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ---- async function handleAsyncMode( client: Client, settings: Settings, - _model: string, + path: string, body: DashScopeImageRequest, flags: GenerateFlags, format: string, @@ -210,14 +222,14 @@ async function handleAsyncMode( settings, () => client.requestJson({ - path: imagePath(), + path, method: "POST", body, async: true, }), "tasks", ); - const taskIds = responses.map((r) => r.output.task_id); + const taskIds = responses.map((response) => response.output.task_id); // --async: return all task IDs immediately if (flags.async) { @@ -234,12 +246,12 @@ async function handleAsyncMode( url: pollUrl, intervalSec: pollInterval, timeoutSec: settings.timeout, - isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", - isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", - getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, - getErrorMessage: (d) => { - const o = (d as DashScopeTaskResponse).output; - return o.message || o.code || undefined; + isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED", + isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED", + getStatus: (data) => (data as DashScopeTaskResponse).output.task_status, + getErrorMessage: (data) => { + const output = (data as DashScopeTaskResponse).output; + return output.message || output.code || undefined; }, }); }); @@ -250,13 +262,13 @@ async function handleAsyncMode( for (const result of results) { if (result.output.choices) { const urls = result.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); imageUrls.push(...urls); } if (result.output.results) { - const urls = result.output.results.map((r) => r.url).filter(Boolean); + const urls = result.output.results.map((item) => item.url).filter(Boolean); if (urls.length > 0 && imageUrls.length === 0) { imageUrls.push(...urls); } @@ -298,8 +310,8 @@ async function saveImages( // Parallel download all images const items = imageUrls.length > 1 - ? imageUrls.map((url, i) => { - const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`; + ? imageUrls.map((url, index) => { + const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`; return { url, destPath: join(outDir, filename) }; }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; diff --git a/packages/commands/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts index 0a5cab6e..4d42c047 100644 --- a/packages/commands/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -61,6 +61,78 @@ describe("e2e: image generate", () => { expect(data.mode).toBe("sync"); expect(data.request?.model).toBe("wan2.7-image"); }); + + test("z-image-turbo dry-run 走 sync multimodal", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "z-image-turbo", + "--prompt", + "一只猫", + "--size", + "1024*1024", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout); + expect(data.mode).toBe("sync"); + expect(data.request?.model).toBe("z-image-turbo"); + }); + + test("qwen-image-plus dry-run 走 sync multimodal", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "qwen-image-plus", + "--prompt", + "一只猫", + "--size", + "1328*1328", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + path?: string; + request?: { model?: string }; + }>(stdout); + expect(data.mode).toBe("sync"); + expect(data.path).toBe("/api/v1/services/aigc/multimodal-generation/generation"); + expect(data.request?.model).toBe("qwen-image-plus"); + }); + + test("wanx2.0-t2i-turbo dry-run 走 text2image prompt 路径", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "wanx2.0-t2i-turbo", + "--prompt", + "一只猫", + "--size", + "1024*1024", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + path?: string; + request?: { model?: string; input?: { prompt?: string; messages?: unknown } }; + }>(stdout); + expect(data.mode).toBe("async"); + expect(data.path).toBe("/api/v1/services/aigc/text2image/image-synthesis"); + expect(data.request?.model).toBe("wanx2.0-t2i-turbo"); + expect(data.request?.input?.prompt).toBe("一只猫"); + expect(data.request?.input?.messages).toBeUndefined(); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 55369aea..119c7bda 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -7,15 +7,26 @@ export function chatPath(): string { } // ---- Image Generation (DashScope) ---- +/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */ export function imagePath(): string { return "/api/v1/services/aigc/image-generation/generation"; } -// Synchronous image generation (qwen-image-2.0 / qwen-image-max series) +/** Sync multimodal API (qwen-image / wan2.7-image / z-image generate; also wan2.6-image edit). */ export function imageSyncPath(): string { return "/api/v1/services/aigc/multimodal-generation/generation"; } +/** Legacy async text-to-image API (wan2.5/2.2/2.1-t2i, wanx-*-t2i). */ +export function imageText2ImagePath(): string { + return "/api/v1/services/aigc/text2image/image-synthesis"; +} + +/** Legacy async image-to-image / edit API (wan2.5-i2i, *imageedit*). */ +export function image2ImagePath(): string { + return "/api/v1/services/aigc/image2image/image-synthesis"; +} + // ---- Video Generation (DashScope) ---- export function videoGeneratePath(): string { return "/api/v1/services/aigc/video-generation/video-synthesis"; diff --git a/packages/core/src/client/image-routes.ts b/packages/core/src/client/image-routes.ts new file mode 100644 index 00000000..b16fb2bf --- /dev/null +++ b/packages/core/src/client/image-routes.ts @@ -0,0 +1,125 @@ +import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from "./endpoints.ts"; + +/** + * DashScope image APIs differ by model family: + * + * Generate (T2I): + * - sync multimodal + messages: qwen-image*, wan2.7-image*, z-image* + * - async image-generation + messages: wan2.6-t2i*, wan2.6-image* + * (wan2.6-image sync multimodal requires 1–4 images, so pure T2I must be async) + * - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i* + * + * Edit (I2I): + * - sync multimodal + messages(+images): qwen-image-*, wan2.6-image*, wan2.7-image*, z-image* + * - async image2image + prompt/images: wan2.5-i2i*, *imageedit* + * - async image-generation + messages(+images): other async fallbacks + */ + +export type ImageApiKind = + | "sync-multimodal" + | "async-image-generation" + | "async-text2image" + | "async-image2image"; + +export interface ImageApiRoute { + kind: ImageApiKind; + path: string; + /** True when the call is synchronous (no X-DashScope-Async / task poll). */ + useSync: boolean; + /** How to shape `input` in the request body. */ + inputStyle: "messages" | "prompt" | "prompt-images"; +} + +/** Models that accept text-only sync multimodal for generate. */ +const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as const; + +/** + * Extra models that use sync multimodal only for edit (messages must include images). + * wan2.6-image generate is async image-generation instead. + */ +const SYNC_EDIT_ONLY_PREFIXES = ["wan2.6-image"] as const; + +function startsWithAny(model: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => model.startsWith(prefix)); +} + +/** True when the model family can use sync multimodal (generate and/or edit). */ +export function isSyncMultimodalImageModel(model: string): boolean { + return ( + startsWithAny(model, SYNC_GENERATE_PREFIXES) || startsWithAny(model, SYNC_EDIT_ONLY_PREFIXES) + ); +} + +function isSyncGenerateModel(model: string): boolean { + return startsWithAny(model, SYNC_GENERATE_PREFIXES); +} + +function isSyncEditModel(model: string): boolean { + return isSyncMultimodalImageModel(model); +} + +/** wan2.5 / wan2.2 / wan2.1 / wanx text-to-image models use the legacy prompt API. */ +export function isLegacyText2ImageModel(model: string): boolean { + if (model.startsWith("wan2.6-t2i") || model.startsWith("wan2.6-image")) return false; + if (isSyncGenerateModel(model)) return false; + if (/^wan2\.[0-5][^-]*-t2i/i.test(model)) return true; + if (/^wanx-v1$/i.test(model)) return true; + if (/^wanx/i.test(model) && /t2i|text2image/i.test(model)) return true; + return false; +} + +/** wan2.5-i2i / *imageedit* use the legacy image2image prompt+images API. */ +export function isLegacyImage2ImageModel(model: string): boolean { + return /wan2\.5-i2i/i.test(model) || /imageedit/i.test(model); +} + +export function resolveImageGenerateApi(model: string): ImageApiRoute { + if (isSyncGenerateModel(model)) { + return { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }; + } + if (isLegacyText2ImageModel(model)) { + return { + kind: "async-text2image", + path: imageText2ImagePath(), + useSync: false, + inputStyle: "prompt", + }; + } + // Includes wan2.6-t2i* and wan2.6-image* (text-only generate). + return { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }; +} + +export function resolveImageEditApi(model: string): ImageApiRoute { + if (isSyncEditModel(model)) { + return { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }; + } + if (isLegacyImage2ImageModel(model)) { + return { + kind: "async-image2image", + path: image2ImagePath(), + useSync: false, + inputStyle: "prompt-images", + }; + } + return { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }; +} diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 536451d3..0e47cbf8 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -3,6 +3,8 @@ export { chatPath, imagePath, imageSyncPath, + imageText2ImagePath, + image2ImagePath, knowledgeChatEndpoint, knowledgeRetrievePath, knowledgeSearchEndpoint, @@ -18,6 +20,15 @@ export { userProfilePath, videoGeneratePath, } from "./endpoints.ts"; +export { + isLegacyImage2ImageModel, + isLegacyText2ImageModel, + isSyncMultimodalImageModel, + resolveImageEditApi, + resolveImageGenerateApi, + type ImageApiKind, + type ImageApiRoute, +} from "./image-routes.ts"; export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; export type { RequestOpts } from "./http.ts"; export { request, requestJson } from "./http.ts"; diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 3fa00929..3d17ea10 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -112,12 +112,19 @@ export interface StreamChunk { export interface DashScopeImageRequest { model: string; - input: { - messages: Array<{ - role: "user"; - content: Array<{ text?: string; image?: string }>; - }>; - }; + input: + | { + messages: Array<{ + role: "user"; + content: Array<{ text?: string; image?: string }>; + }>; + } + | { + prompt: string; + /** Required by image2image models such as wan2.5-i2i-preview. */ + images?: string[]; + negative_prompt?: string; + }; parameters?: { size?: string; n?: number; diff --git a/packages/core/tests/image-routes.test.ts b/packages/core/tests/image-routes.test.ts new file mode 100644 index 00000000..fe05a397 --- /dev/null +++ b/packages/core/tests/image-routes.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "vite-plus/test"; +import { + isLegacyImage2ImageModel, + isLegacyText2ImageModel, + isSyncMultimodalImageModel, + resolveImageEditApi, + resolveImageGenerateApi, +} from "../src/client/image-routes.ts"; + +test("sync multimodal family covers qwen-image, wan2.6/2.7 image, and z-image", () => { + expect(isSyncMultimodalImageModel("qwen-image-2.0")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-2.0-pro")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-plus")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-max")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.7-image")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.6-image")).toBe(true); + expect(isSyncMultimodalImageModel("z-image-turbo")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.6-t2i")).toBe(false); +}); + +test("legacy text2image covers wan2.5/2.2/2.1 t2i and wanx but not wan2.6-t2i/image", () => { + expect(isLegacyText2ImageModel("wan2.2-t2i-plus")).toBe(true); + expect(isLegacyText2ImageModel("wan2.5-t2i-preview")).toBe(true); + expect(isLegacyText2ImageModel("wan2.1-t2i-turbo")).toBe(true); + expect(isLegacyText2ImageModel("wanx2.0-t2i-turbo")).toBe(true); + expect(isLegacyText2ImageModel("wan2.6-t2i")).toBe(false); + expect(isLegacyText2ImageModel("wan2.6-image")).toBe(false); + expect(isLegacyText2ImageModel("wan2.7-image")).toBe(false); +}); + +test("legacy image2image covers wan2.5-i2i and imageedit models", () => { + expect(isLegacyImage2ImageModel("wan2.5-i2i-preview")).toBe(true); + expect(isLegacyImage2ImageModel("wanx2.1-imageedit")).toBe(true); + expect(isLegacyImage2ImageModel("wan2.6-image")).toBe(false); +}); + +test("resolveImageGenerateApi picks path and input style by model family", () => { + expect(resolveImageGenerateApi("wanx2.0-t2i-turbo")).toMatchObject({ + kind: "async-text2image", + path: "/api/v1/services/aigc/text2image/image-synthesis", + inputStyle: "prompt", + useSync: false, + }); + expect(resolveImageGenerateApi("wan2.2-t2i-plus")).toMatchObject({ + kind: "async-text2image", + path: "/api/v1/services/aigc/text2image/image-synthesis", + inputStyle: "prompt", + useSync: false, + }); + expect(resolveImageGenerateApi("wan2.6-t2i")).toMatchObject({ + kind: "async-image-generation", + path: "/api/v1/services/aigc/image-generation/generation", + inputStyle: "messages", + useSync: false, + }); + expect(resolveImageGenerateApi("wan2.6-image")).toMatchObject({ + kind: "async-image-generation", + path: "/api/v1/services/aigc/image-generation/generation", + inputStyle: "messages", + useSync: false, + }); + expect(resolveImageGenerateApi("qwen-image-2.0")).toMatchObject({ + kind: "sync-multimodal", + path: "/api/v1/services/aigc/multimodal-generation/generation", + inputStyle: "messages", + useSync: true, + }); + expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ + kind: "sync-multimodal", + useSync: true, + }); + expect(resolveImageGenerateApi("qwen-image-plus")).toMatchObject({ + kind: "sync-multimodal", + path: "/api/v1/services/aigc/multimodal-generation/generation", + inputStyle: "messages", + useSync: true, + }); + expect(resolveImageGenerateApi("wan2.7-image")).toMatchObject({ + kind: "sync-multimodal", + useSync: true, + }); +}); + +test("resolveImageEditApi picks path and input style by model family", () => { + expect(resolveImageEditApi("wan2.5-i2i-preview")).toMatchObject({ + kind: "async-image2image", + path: "/api/v1/services/aigc/image2image/image-synthesis", + inputStyle: "prompt-images", + useSync: false, + }); + expect(resolveImageEditApi("wan2.6-image")).toMatchObject({ + kind: "sync-multimodal", + path: "/api/v1/services/aigc/multimodal-generation/generation", + inputStyle: "messages", + useSync: true, + }); + expect(resolveImageEditApi("wan2.7-image")).toMatchObject({ + kind: "sync-multimodal", + useSync: true, + }); +}); diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 48e32f63..add280b2 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -4,8 +4,8 @@ */ import { chatPath, - imagePath, - imageSyncPath, + resolveImageEditApi, + resolveImageGenerateApi, videoGeneratePath, taskPath, speechSynthesizePath, @@ -147,12 +147,6 @@ export async function visionDescribe( // --- image/generate --- -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; - -function isSyncImageModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); -} - export interface ImageGenerateInput { prompt?: string; model?: string; @@ -178,61 +172,72 @@ export async function imageGenerate( } const model = input.model || "qwen-image-2.0"; - const useSync = isSyncImageModel(model); + const route = resolveImageGenerateApi(model); const n = input.n ?? 1; const promptExtend = resolveBooleanFlag( input["prompt-extend"], - useSync ? true : undefined, + route.useSync ? true : undefined, "prompt-extend", ); - const body: DashScopeImageRequest = { - model, - input: { - messages: [{ role: "user", content: [{ text: input.prompt }] }], - }, - parameters: { - size: resolveImageSize(input.size, useSync), - n, - seed: input.seed, - prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), - negative_prompt: input["negative-prompt"] || undefined, - }, + const parameters: NonNullable = { + size: resolveImageSize(input.size, route.useSync), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: resolveWatermark(input.watermark), }; - if (useSync) { - const url = imageSyncPath(); + const body: DashScopeImageRequest = + route.inputStyle === "prompt" + ? { + model, + input: { + prompt: input.prompt, + negative_prompt: input["negative-prompt"] || undefined, + }, + parameters, + } + : { + model, + input: { + messages: [{ role: "user", content: [{ text: input.prompt }] }], + }, + parameters: { + ...parameters, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + + if (route.useSync) { const response = await env.client.requestJson({ - path: url, + path: route.path, method: "POST", body, signal: ctx.signal, }); const urls = response.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; - } else { - // Async mode: submit then poll - const url = imagePath(); - const asyncResp = await env.client.requestJson({ - path: url, - method: "POST", - body, - async: true, - signal: ctx.signal, - }); - const taskId = asyncResp.output.task_id; - const result = await pollTask(env, taskId, ctx); - const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; - const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); - if (saved) result.saved = saved; - return result; } + + const asyncResp = await env.client.requestJson({ + path: route.path, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(env, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; } // --- image/edit --- @@ -264,70 +269,88 @@ export async function imageEdit( const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : []; const model = input.model || "qwen-image-2.0"; - const useSync = isSyncImageModel(model); + const route = resolveImageEditApi(model); const n = input.n ?? 1; const promptExtend = resolveBooleanFlag( input["prompt-extend"], - useSync ? true : undefined, + route.useSync ? true : undefined, "prompt-extend", ); - const content: Array<{ text?: string; image?: string }> = []; - for (const img of images) { - let imageUrl = img; - if (isLocalFile(img)) { - imageUrl = await env.client.uploadFile(img, model, { signal: ctx.signal }); + const resolvedImages: string[] = []; + for (const image of images) { + let imageUrl = image; + if (isLocalFile(image)) { + imageUrl = await env.client.uploadFile(image, model, { signal: ctx.signal }); } - content.push({ image: imageUrl }); + resolvedImages.push(imageUrl); } - content.push({ text: input.prompt }); - const body: DashScopeImageRequest = { - model, - input: { - messages: [{ role: "user", content }], - }, - parameters: { - size: resolveImageSize(input.size, useSync), - n, - seed: input.seed, - prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), - negative_prompt: input["negative-prompt"] || undefined, - }, + const parameters: NonNullable = { + size: resolveImageSize(input.size, route.useSync), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: resolveWatermark(input.watermark), }; - if (useSync) { - const url = imageSyncPath(); + let body: DashScopeImageRequest; + if (route.inputStyle === "prompt-images") { + body = { + model, + input: { + prompt: input.prompt, + images: resolvedImages, + negative_prompt: input["negative-prompt"] || undefined, + }, + parameters, + }; + } else { + const content: Array<{ text?: string; image?: string }> = resolvedImages.map((imageUrl) => ({ + image: imageUrl, + })); + content.push({ text: input.prompt }); + body = { + model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + ...parameters, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + } + + if (route.useSync) { const response = await env.client.requestJson({ - path: url, + path: route.path, method: "POST", body, signal: ctx.signal, }); const urls = response.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; - } else { - const url = imagePath(); - const asyncResp = await env.client.requestJson({ - path: url, - method: "POST", - body, - async: true, - signal: ctx.signal, - }); - const taskId = asyncResp.output.task_id; - const result = await pollTask(env, taskId, ctx); - const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; - const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); - if (saved) result.saved = saved; - return result; } + + const asyncResp = await env.client.requestJson({ + path: route.path, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(env, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; } /** diff --git a/skills/bailian-cli/reference/image.md b/skills/bailian-cli/reference/image.md index 84419f8b..e3bdd92a 100644 --- a/skills/bailian-cli/reference/image.md +++ b/skills/bailian-cli/reference/image.md @@ -65,6 +65,10 @@ bl image edit --image https://example.com/photo.png --prompt "Remove the person" bl image edit --image ./photo.png --prompt "Change the style" --model wan2.7-image ``` +```bash +bl image edit --image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview +``` + ```bash bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false ``` @@ -127,6 +131,14 @@ bl image generate --prompt "An alien in the space" --watermark false bl image generate --prompt "sunset" --model wan2.6-t2i --async --quiet ``` +```bash +bl image generate --prompt "plush doll" --model z-image-turbo --size 1024*1024 +``` + +```bash +bl image generate --prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024 +``` + ```bash bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro ``` From c4f5bb09c689b4bb5692f043523be78fa52e3d96 Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 27 Jul 2026 10:17:09 +0800 Subject: [PATCH 2/3] fix(image): resolve size and prompt_extend by model profile Stop inferring size/prompt_extend from sync vs async; use per-family sizeProfile. wanx*-imageedit uses function+base_image_url; bare qwen-image uses the fixed resolution table. --- packages/commands/src/commands/image/edit.ts | 89 +++++--- .../commands/src/commands/image/generate.ts | 10 +- .../commands/tests/e2e/image-edit.e2e.test.ts | 72 +++++++ .../tests/e2e/image-generate.e2e.test.ts | 59 ++++++ packages/core/src/client/image-routes.ts | 199 +++++++++++++----- packages/core/src/client/index.ts | 5 + packages/core/src/types/api.ts | 8 + packages/core/tests/image-routes.test.ts | 109 ++++++---- packages/runtime/src/pipeline/steps/bl-api.ts | 29 ++- packages/runtime/src/utils/image-size.ts | 115 ++++++++-- packages/runtime/tests/image-size.test.ts | 28 +++ skills/bailian-cli/reference/image.md | 41 ++-- 12 files changed, 602 insertions(+), 162 deletions(-) create mode 100644 packages/runtime/tests/image-size.test.ts diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index c4a1d08f..95d5054f 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -31,12 +31,6 @@ import { resolveImageSize } from "bailian-cli-runtime"; import { join } from "path"; import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; -const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; - -function enablesPromptExtendByDefault(model: string): boolean { - return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); -} - const EDIT_FLAGS = { image: { type: "array", @@ -71,6 +65,12 @@ const EDIT_FLAGS = { valueHint: "", description: "Negative prompt to exclude unwanted content", }, + function: { + type: "string", + valueHint: "", + description: + "wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit", + }, promptExtend: { type: "boolean", valueHint: "", @@ -109,6 +109,7 @@ export default defineCommand({ '--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro', '--image ./photo.png --prompt "Change the style" --model wan2.7-image', '--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview', + '--image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all', '--image ./photo.png --prompt "Replace the background with a beach" --watermark false', ], async run(ctx) { @@ -133,14 +134,14 @@ export default defineCommand({ const promptExtend = resolveBooleanFlag( flags.promptExtend, - enablesPromptExtendByDefault(model) ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); const watermark = resolveWatermark(flags.watermark); const parameters: NonNullable = { - size: resolveImageSize(flags.size, route.useSync), + size: resolveImageSize(flags.size, route.sizeProfile), n, seed: flags.seed, prompt_extend: promptExtend, @@ -148,7 +149,24 @@ export default defineCommand({ }; let body: DashScopeImageRequest; - if (route.inputStyle === "prompt-images") { + if (route.inputStyle === "function-base-image") { + const baseImageUrl = resolvedImages[0]; + if (!baseImageUrl) { + throw new BailianError( + "wanx*-imageedit requires at least one --image as base_image_url.", + ExitCode.USAGE, + ); + } + body = { + model, + input: { + function: flags.function || "description_edit", + prompt, + base_image_url: baseImageUrl, + }, + parameters, + }; + } else if (route.inputStyle === "prompt-images") { body = { model, input: { @@ -186,26 +204,39 @@ export default defineCommand({ const format = detectOutputFormat(settings.output); if (settings.dryRun) { - const previewBody = - "messages" in body.input - ? { - ...body, - input: { - messages: body.input.messages.map((message) => ({ - ...message, - content: message.content.map((item) => - item.image ? { ...item, image: redactDataUri(item.image) } : item, - ), - })), - }, - } - : { - ...body, - input: { - ...body.input, - images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)), - }, - }; + let previewBody: DashScopeImageRequest = body; + if ("messages" in body.input) { + previewBody = { + ...body, + input: { + messages: body.input.messages.map((message) => ({ + ...message, + content: message.content.map((item) => + item.image ? { ...item, image: redactDataUri(item.image) } : item, + ), + })), + }, + }; + } else if ("images" in body.input) { + previewBody = { + ...body, + input: { + ...body.input, + images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)), + }, + }; + } else if ("base_image_url" in body.input) { + previewBody = { + ...body, + input: { + ...body.input, + base_image_url: redactDataUri(body.input.base_image_url), + mask_image_url: body.input.mask_image_url + ? redactDataUri(body.input.mask_image_url) + : undefined, + }, + }; + } emitResult( { request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path }, format, diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 6a7db1b6..31eaab52 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -30,12 +30,6 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai import { join } from "path"; -const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; - -function enablesPromptExtendByDefault(model: string): boolean { - return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); -} - const GENERATE_FLAGS = { prompt: { type: "string", valueHint: "", description: "Image description", required: true }, model: { @@ -115,13 +109,13 @@ export default defineCommand({ const route = resolveImageGenerateApi(model); const defaultSize = "1:1"; const sizeInput = flags.size || defaultSize; - const size = resolveImageSize(sizeInput, route.useSync); + const size = resolveImageSize(sizeInput, route.sizeProfile); const n = flags.n ?? 1; const concurrent = getConcurrency(flags); const promptExtend = resolveBooleanFlag( flags.promptExtend, - enablesPromptExtendByDefault(model) ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); diff --git a/packages/commands/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts index 7476a8d5..3b405fa3 100644 --- a/packages/commands/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -96,6 +96,78 @@ describe("e2e: image edit", () => { "data:image/png;base64,", ); }); + + test("wan2.5-i2i-preview dry-run 走 prompt+images", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "edit", + "--model", + "wan2.5-i2i-preview", + "--image", + "https://example.com/source.png", + "--prompt", + "Place on a table", + "--size", + "1:1", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + path?: string; + request?: { + input?: { prompt?: string; images?: string[]; messages?: unknown }; + parameters?: { size?: string }; + }; + }>(stdout); + expect(data.mode).toBe("async"); + expect(data.path).toBe("/api/v1/services/aigc/image2image/image-synthesis"); + expect(data.request?.input?.prompt).toBe("Place on a table"); + expect(data.request?.input?.images).toEqual(["https://example.com/source.png"]); + expect(data.request?.input?.messages).toBeUndefined(); + expect(data.request?.parameters?.size).toBe("1280*1280"); + }); + + test("wanx2.1-imageedit dry-run 走 function + base_image_url", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "edit", + "--model", + "wanx2.1-imageedit", + "--image", + "https://example.com/source.png", + "--prompt", + "转换成绘本风格", + "--function", + "stylization_all", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + path?: string; + request?: { + input?: { + function?: string; + prompt?: string; + base_image_url?: string; + images?: unknown; + messages?: unknown; + }; + }; + }>(stdout); + expect(data.mode).toBe("async"); + expect(data.path).toBe("/api/v1/services/aigc/image2image/image-synthesis"); + expect(data.request?.input?.function).toBe("stylization_all"); + expect(data.request?.input?.prompt).toBe("转换成绘本风格"); + expect(data.request?.input?.base_image_url).toBe("https://example.com/source.png"); + expect(data.request?.input?.images).toBeUndefined(); + expect(data.request?.input?.messages).toBeUndefined(); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => { diff --git a/packages/commands/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts index 4d42c047..913567ec 100644 --- a/packages/commands/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -107,6 +107,65 @@ describe("e2e: image generate", () => { expect(data.request?.model).toBe("qwen-image-plus"); }); + test("qwen-image-plus 默认 1:1 映射为 1328*1328", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "qwen-image-plus", + "--prompt", + "一只猫", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { parameters?: { size?: string; prompt_extend?: boolean } }; + }>(stdout); + expect(data.request?.parameters?.size).toBe("1328*1328"); + }); + + test("z-image-turbo 默认 prompt_extend 为 false", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "z-image-turbo", + "--prompt", + "一只猫", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { parameters?: { size?: string; prompt_extend?: boolean } }; + }>(stdout); + expect(data.request?.parameters?.prompt_extend).toBe(false); + expect(data.request?.parameters?.size).toBe("1024*1024"); + }); + + test("wanx-v1 默认 1:1 映射为 1024*1024", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "wanx-v1", + "--prompt", + "一只猫", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { parameters?: { size?: string }; input?: { prompt?: string } }; + }>(stdout); + expect(data.request?.parameters?.size).toBe("1024*1024"); + expect(data.request?.input?.prompt).toBe("一只猫"); + }); + test("wanx2.0-t2i-turbo dry-run 走 text2image prompt 路径", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", diff --git a/packages/core/src/client/image-routes.ts b/packages/core/src/client/image-routes.ts index b16fb2bf..59c28953 100644 --- a/packages/core/src/client/image-routes.ts +++ b/packages/core/src/client/image-routes.ts @@ -10,8 +10,10 @@ import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from " * - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i* * * Edit (I2I): - * - sync multimodal + messages(+images): qwen-image-*, wan2.6-image*, wan2.7-image*, z-image* - * - async image2image + prompt/images: wan2.5-i2i*, *imageedit* + * - sync multimodal + messages(+images): qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image* + * (pure T2I models such as z-image / qwen-image-plus / qwen-image-max are NOT edit models) + * - async image2image + prompt/images: wan2.5-i2i* + * - async image2image + function/base_image_url: *imageedit* (e.g. wanx2.1-imageedit) * - async image-generation + messages(+images): other async fallbacks */ @@ -21,33 +23,56 @@ export type ImageApiKind = | "async-text2image" | "async-image2image"; +/** Model-family size presets — not inferred from sync/async. */ +export type ImageSizeProfile = + | "qwen-image-2.0" + | "qwen-image-fixed" + | "wan27" + | "z-image" + | "wan26" + | "wan-legacy" + | "wanx-v1" + | "wan25-i2i"; + +export type ImageInputStyle = "messages" | "prompt" | "prompt-images" | "function-base-image"; + export interface ImageApiRoute { kind: ImageApiKind; path: string; /** True when the call is synchronous (no X-DashScope-Async / task poll). */ useSync: boolean; /** How to shape `input` in the request body. */ - inputStyle: "messages" | "prompt" | "prompt-images"; + inputStyle: ImageInputStyle; + /** Ratio → pixel map family for `--size`. */ + sizeProfile: ImageSizeProfile; + /** + * CLI default when `--prompt-extend` is omitted. + * `undefined` means omit the parameter (leave to DashScope default). + */ + promptExtendDefault?: boolean; } /** Models that accept text-only sync multimodal for generate. */ const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as const; /** - * Extra models that use sync multimodal only for edit (messages must include images). - * wan2.6-image generate is async image-generation instead. + * Models that support sync multimodal edit (messages must include images). + * Pure T2I models (z-image / qwen-image-plus / qwen-image-max) are excluded. */ -const SYNC_EDIT_ONLY_PREFIXES = ["wan2.6-image"] as const; +const SYNC_EDIT_PREFIXES = [ + "qwen-image-2.0", + "qwen-image-edit", + "wan2.7-image", + "wan2.6-image", +] as const; function startsWithAny(model: string, prefixes: readonly string[]): boolean { return prefixes.some((prefix) => model.startsWith(prefix)); } -/** True when the model family can use sync multimodal (generate and/or edit). */ +/** True when the model family can use sync multimodal for generate. */ export function isSyncMultimodalImageModel(model: string): boolean { - return ( - startsWithAny(model, SYNC_GENERATE_PREFIXES) || startsWithAny(model, SYNC_EDIT_ONLY_PREFIXES) - ); + return startsWithAny(model, SYNC_GENERATE_PREFIXES) || model.startsWith("wan2.6-image"); } function isSyncGenerateModel(model: string): boolean { @@ -55,7 +80,7 @@ function isSyncGenerateModel(model: string): boolean { } function isSyncEditModel(model: string): boolean { - return isSyncMultimodalImageModel(model); + return startsWithAny(model, SYNC_EDIT_PREFIXES); } /** wan2.5 / wan2.2 / wan2.1 / wanx text-to-image models use the legacy prompt API. */ @@ -68,58 +93,134 @@ export function isLegacyText2ImageModel(model: string): boolean { return false; } -/** wan2.5-i2i / *imageedit* use the legacy image2image prompt+images API. */ +/** wan2.5-i2i uses the legacy image2image prompt+images API. */ export function isLegacyImage2ImageModel(model: string): boolean { - return /wan2\.5-i2i/i.test(model) || /imageedit/i.test(model); + return /wan2\.5-i2i/i.test(model); +} + +/** wanx*-imageedit uses function + base_image_url (not prompt+images). */ +export function isWanxFunctionImageEditModel(model: string): boolean { + return /imageedit/i.test(model); +} + +export function resolveImageSizeProfile(model: string): ImageSizeProfile { + if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-edit")) { + return "qwen-image-2.0"; + } + // Remaining qwen-image* (plus / max / bare qwen-image) share the fixed table. + if (model.startsWith("qwen-image")) { + return "qwen-image-fixed"; + } + if (model.startsWith("wan2.7-image")) return "wan27"; + if (model.startsWith("z-image")) return "z-image"; + if ( + model.startsWith("wan2.6-t2i") || + model.startsWith("wan2.6-image") || + model.startsWith("wan2.5-t2i") + ) { + return "wan26"; + } + if (/^wanx-v1$/i.test(model)) return "wanx-v1"; + if (/wan2\.5-i2i/i.test(model)) return "wan25-i2i"; + if (isLegacyText2ImageModel(model)) return "wan-legacy"; + return "wan26"; +} + +/** Official / CLI defaults for prompt_extend when the flag is omitted. */ +export function resolvePromptExtendDefault(model: string): boolean | undefined { + if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-max")) return true; + // Z-Image docs default prompt_extend to false. + if (model.startsWith("z-image")) return false; + return undefined; +} + +function buildRoute( + partial: Omit, + model: string, +): ImageApiRoute { + return { + ...partial, + sizeProfile: resolveImageSizeProfile(model), + promptExtendDefault: resolvePromptExtendDefault(model), + }; } export function resolveImageGenerateApi(model: string): ImageApiRoute { if (isSyncGenerateModel(model)) { - return { - kind: "sync-multimodal", - path: imageSyncPath(), - useSync: true, - inputStyle: "messages", - }; + return buildRoute( + { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }, + model, + ); } if (isLegacyText2ImageModel(model)) { - return { - kind: "async-text2image", - path: imageText2ImagePath(), - useSync: false, - inputStyle: "prompt", - }; + return buildRoute( + { + kind: "async-text2image", + path: imageText2ImagePath(), + useSync: false, + inputStyle: "prompt", + }, + model, + ); } // Includes wan2.6-t2i* and wan2.6-image* (text-only generate). - return { - kind: "async-image-generation", - path: imagePath(), - useSync: false, - inputStyle: "messages", - }; + return buildRoute( + { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }, + model, + ); } export function resolveImageEditApi(model: string): ImageApiRoute { if (isSyncEditModel(model)) { - return { - kind: "sync-multimodal", - path: imageSyncPath(), - useSync: true, - inputStyle: "messages", - }; + return buildRoute( + { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }, + model, + ); + } + if (isWanxFunctionImageEditModel(model)) { + return buildRoute( + { + kind: "async-image2image", + path: image2ImagePath(), + useSync: false, + inputStyle: "function-base-image", + }, + model, + ); } if (isLegacyImage2ImageModel(model)) { - return { - kind: "async-image2image", - path: image2ImagePath(), - useSync: false, - inputStyle: "prompt-images", - }; + return buildRoute( + { + kind: "async-image2image", + path: image2ImagePath(), + useSync: false, + inputStyle: "prompt-images", + }, + model, + ); } - return { - kind: "async-image-generation", - path: imagePath(), - useSync: false, - inputStyle: "messages", - }; + return buildRoute( + { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }, + model, + ); } diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 0e47cbf8..cb06c21e 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -24,10 +24,15 @@ export { isLegacyImage2ImageModel, isLegacyText2ImageModel, isSyncMultimodalImageModel, + isWanxFunctionImageEditModel, resolveImageEditApi, resolveImageGenerateApi, + resolveImageSizeProfile, + resolvePromptExtendDefault, type ImageApiKind, type ImageApiRoute, + type ImageInputStyle, + type ImageSizeProfile, } from "./image-routes.ts"; export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; export type { RequestOpts } from "./http.ts"; diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 3d17ea10..c6c00ec0 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -124,6 +124,13 @@ export interface DashScopeImageRequest { /** Required by image2image models such as wan2.5-i2i-preview. */ images?: string[]; negative_prompt?: string; + } + | { + /** Required by wanx*-imageedit models. */ + function: string; + prompt: string; + base_image_url: string; + mask_image_url?: string; }; parameters?: { size?: string; @@ -132,6 +139,7 @@ export interface DashScopeImageRequest { prompt_extend?: boolean; watermark?: boolean; negative_prompt?: string; + strength?: number; }; } diff --git a/packages/core/tests/image-routes.test.ts b/packages/core/tests/image-routes.test.ts index fe05a397..8c827010 100644 --- a/packages/core/tests/image-routes.test.ts +++ b/packages/core/tests/image-routes.test.ts @@ -3,8 +3,11 @@ import { isLegacyImage2ImageModel, isLegacyText2ImageModel, isSyncMultimodalImageModel, + isWanxFunctionImageEditModel, resolveImageEditApi, resolveImageGenerateApi, + resolveImageSizeProfile, + resolvePromptExtendDefault, } from "../src/client/image-routes.ts"; test("sync multimodal family covers qwen-image, wan2.6/2.7 image, and z-image", () => { @@ -23,79 +26,111 @@ test("legacy text2image covers wan2.5/2.2/2.1 t2i and wanx but not wan2.6-t2i/im expect(isLegacyText2ImageModel("wan2.5-t2i-preview")).toBe(true); expect(isLegacyText2ImageModel("wan2.1-t2i-turbo")).toBe(true); expect(isLegacyText2ImageModel("wanx2.0-t2i-turbo")).toBe(true); + expect(isLegacyText2ImageModel("wanx-v1")).toBe(true); expect(isLegacyText2ImageModel("wan2.6-t2i")).toBe(false); expect(isLegacyText2ImageModel("wan2.6-image")).toBe(false); expect(isLegacyText2ImageModel("wan2.7-image")).toBe(false); }); -test("legacy image2image covers wan2.5-i2i and imageedit models", () => { +test("legacy image2image is wan2.5-i2i only; wanx imageedit uses function protocol", () => { expect(isLegacyImage2ImageModel("wan2.5-i2i-preview")).toBe(true); - expect(isLegacyImage2ImageModel("wanx2.1-imageedit")).toBe(true); - expect(isLegacyImage2ImageModel("wan2.6-image")).toBe(false); + expect(isLegacyImage2ImageModel("wanx2.1-imageedit")).toBe(false); + expect(isWanxFunctionImageEditModel("wanx2.1-imageedit")).toBe(true); + expect(isWanxFunctionImageEditModel("wan2.5-i2i-preview")).toBe(false); }); -test("resolveImageGenerateApi picks path and input style by model family", () => { +test("size profiles are model-specific, not sync/async", () => { + expect(resolveImageSizeProfile("qwen-image-2.0")).toBe("qwen-image-2.0"); + expect(resolveImageSizeProfile("qwen-image")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("qwen-image-plus")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("qwen-image-max")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("wan2.7-image")).toBe("wan27"); + expect(resolveImageSizeProfile("z-image-turbo")).toBe("z-image"); + expect(resolveImageSizeProfile("wan2.6-t2i")).toBe("wan26"); + expect(resolveImageSizeProfile("wanx-v1")).toBe("wanx-v1"); + expect(resolveImageSizeProfile("wan2.5-i2i-preview")).toBe("wan25-i2i"); + expect(resolveImageSizeProfile("wan2.2-t2i-plus")).toBe("wan-legacy"); +}); + +test("prompt_extend defaults follow model docs", () => { + expect(resolvePromptExtendDefault("qwen-image-2.0")).toBe(true); + expect(resolvePromptExtendDefault("qwen-image-max")).toBe(true); + expect(resolvePromptExtendDefault("z-image-turbo")).toBe(false); + expect(resolvePromptExtendDefault("wan2.7-image")).toBeUndefined(); + expect(resolvePromptExtendDefault("qwen-image-plus")).toBeUndefined(); +}); + +test("resolveImageGenerateApi picks path, input style, and size profile", () => { expect(resolveImageGenerateApi("wanx2.0-t2i-turbo")).toMatchObject({ kind: "async-text2image", path: "/api/v1/services/aigc/text2image/image-synthesis", inputStyle: "prompt", useSync: false, + sizeProfile: "wan-legacy", }); - expect(resolveImageGenerateApi("wan2.2-t2i-plus")).toMatchObject({ - kind: "async-text2image", - path: "/api/v1/services/aigc/text2image/image-synthesis", + expect(resolveImageGenerateApi("wanx-v1")).toMatchObject({ + sizeProfile: "wanx-v1", inputStyle: "prompt", - useSync: false, }); expect(resolveImageGenerateApi("wan2.6-t2i")).toMatchObject({ kind: "async-image-generation", - path: "/api/v1/services/aigc/image-generation/generation", - inputStyle: "messages", - useSync: false, - }); - expect(resolveImageGenerateApi("wan2.6-image")).toMatchObject({ - kind: "async-image-generation", - path: "/api/v1/services/aigc/image-generation/generation", - inputStyle: "messages", - useSync: false, + sizeProfile: "wan26", }); expect(resolveImageGenerateApi("qwen-image-2.0")).toMatchObject({ kind: "sync-multimodal", - path: "/api/v1/services/aigc/multimodal-generation/generation", - inputStyle: "messages", - useSync: true, + sizeProfile: "qwen-image-2.0", + promptExtendDefault: true, }); - expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ + expect(resolveImageGenerateApi("qwen-image-plus")).toMatchObject({ kind: "sync-multimodal", - useSync: true, + sizeProfile: "qwen-image-fixed", }); - expect(resolveImageGenerateApi("qwen-image-plus")).toMatchObject({ + expect(resolveImageGenerateApi("qwen-image")).toMatchObject({ kind: "sync-multimodal", - path: "/api/v1/services/aigc/multimodal-generation/generation", - inputStyle: "messages", - useSync: true, + sizeProfile: "qwen-image-fixed", }); - expect(resolveImageGenerateApi("wan2.7-image")).toMatchObject({ + expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ kind: "sync-multimodal", - useSync: true, + sizeProfile: "z-image", + promptExtendDefault: false, }); }); -test("resolveImageEditApi picks path and input style by model family", () => { - expect(resolveImageEditApi("wan2.5-i2i-preview")).toMatchObject({ - kind: "async-image2image", - path: "/api/v1/services/aigc/image2image/image-synthesis", - inputStyle: "prompt-images", - useSync: false, +test("resolveImageEditApi excludes pure T2I models from sync edit", () => { + expect(resolveImageEditApi("wan2.7-image")).toMatchObject({ + kind: "sync-multimodal", + inputStyle: "messages", + useSync: true, }); expect(resolveImageEditApi("wan2.6-image")).toMatchObject({ kind: "sync-multimodal", - path: "/api/v1/services/aigc/multimodal-generation/generation", - inputStyle: "messages", useSync: true, }); - expect(resolveImageEditApi("wan2.7-image")).toMatchObject({ + expect(resolveImageEditApi("qwen-image-2.0")).toMatchObject({ kind: "sync-multimodal", useSync: true, }); + // Pure T2I models fall through to async image-generation, not sync edit. + expect(resolveImageEditApi("z-image-turbo")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("qwen-image-plus")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("qwen-image-max")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("wan2.5-i2i-preview")).toMatchObject({ + kind: "async-image2image", + inputStyle: "prompt-images", + sizeProfile: "wan25-i2i", + }); + expect(resolveImageEditApi("wanx2.1-imageedit")).toMatchObject({ + kind: "async-image2image", + inputStyle: "function-base-image", + path: "/api/v1/services/aigc/image2image/image-synthesis", + }); }); diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index add280b2..f69f2c12 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -177,12 +177,12 @@ export async function imageGenerate( const promptExtend = resolveBooleanFlag( input["prompt-extend"], - route.useSync ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); const parameters: NonNullable = { - size: resolveImageSize(input.size, route.useSync), + size: resolveImageSize(input.size, route.sizeProfile), n, seed: input.seed, prompt_extend: promptExtend, @@ -252,6 +252,7 @@ export interface ImageEditInput { "negative-prompt"?: string; "prompt-extend"?: boolean | string; watermark?: boolean | string; + function?: string; "out-dir"?: string; "out-prefix"?: string; } @@ -274,7 +275,7 @@ export async function imageEdit( const promptExtend = resolveBooleanFlag( input["prompt-extend"], - route.useSync ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); @@ -288,7 +289,7 @@ export async function imageEdit( } const parameters: NonNullable = { - size: resolveImageSize(input.size, route.useSync), + size: resolveImageSize(input.size, route.sizeProfile), n, seed: input.seed, prompt_extend: promptExtend, @@ -296,7 +297,25 @@ export async function imageEdit( }; let body: DashScopeImageRequest; - if (route.inputStyle === "prompt-images") { + if (route.inputStyle === "function-base-image") { + const baseImageUrl = resolvedImages[0]; + if (!baseImageUrl) { + throw new PipelineError( + "missing_input", + "image/edit with wanx*-imageedit requires at least one image", + { step: "image/edit" }, + ); + } + body = { + model, + input: { + function: input.function || "description_edit", + prompt: input.prompt, + base_image_url: baseImageUrl, + }, + parameters, + }; + } else if (route.inputStyle === "prompt-images") { body = { model, input: { diff --git a/packages/runtime/src/utils/image-size.ts b/packages/runtime/src/utils/image-size.ts index b986b5ca..39590dc4 100644 --- a/packages/runtime/src/utils/image-size.ts +++ b/packages/runtime/src/utils/image-size.ts @@ -1,19 +1,15 @@ +import type { ImageSizeProfile } from "bailian-cli-core"; + /** - * Resolve image `size` flag for image generate/edit. - * - * Users may pass either a ratio (e.g. "1:1", "3:4", "16:9") or a pixel size - * (e.g. "2048*2048"). The DashScope API only accepts the pixel format, so we - * map known ratios to the recommended pixel size for each model family. - * - * Sync models (qwen-image-2.0 / qwen-image-max / qwen-image-edit-2.0): - * higher-resolution presets. - * - * Async models (wanx2.x): smaller presets. + * Resolve image `--size` for generate/edit by model-family profile. * - * Pixel-format input is passed through unchanged. + * Users may pass a ratio (e.g. "1:1") or pixels (e.g. "2048*2048"). + * Pixel input is passed through; ratios are mapped per model profile. + * Do not infer size from sync/async — that mismatches model constraints. */ -export const SYNC_RATIO_MAP: Record = { +/** qwen-image-2.0 / qwen-image-edit recommended high-res presets. */ +export const QWEN_IMAGE_20_RATIO_MAP: Record = { "16:9": "2688*1536", "9:16": "1536*2688", "1:1": "2048*2048", @@ -21,7 +17,8 @@ export const SYNC_RATIO_MAP: Record = { "3:4": "1728*2368", }; -export const ASYNC_RATIO_MAP: Record = { +/** qwen-image-plus / qwen-image-max fixed resolution presets. */ +export const QWEN_IMAGE_FIXED_RATIO_MAP: Record = { "16:9": "1664*928", "4:3": "1472*1104", "1:1": "1328*1328", @@ -29,11 +26,97 @@ export const ASYNC_RATIO_MAP: Record = { "9:16": "928*1664", }; -/** Resolve `--size` value: accept ratio (3:4) or pixel (W*H) format. */ +/** wan2.7-image* — default 2K square for 1:1. */ +export const WAN27_RATIO_MAP: Record = { + "16:9": "2688*1536", + "9:16": "1536*2688", + "1:1": "2048*2048", + "4:3": "2368*1728", + "3:4": "1728*2368", +}; + +/** z-image* recommended ~1K presets. */ +export const Z_IMAGE_RATIO_MAP: Record = { + "1:1": "1024*1024", + "16:9": "1280*720", + "9:16": "720*1280", + "4:3": "1152*864", + "3:4": "864*1152", + "3:2": "1248*832", + "2:3": "832*1248", +}; + +/** wan2.6-t2i / wan2.6-image / wan2.5-t2i — ~1280 class. */ +export const WAN26_RATIO_MAP: Record = { + "1:1": "1280*1280", + "16:9": "1280*720", + "9:16": "720*1280", + "4:3": "1472*1104", + "3:4": "1104*1472", +}; + +/** wan2.2 and earlier t2i / wanx*-t2i — 1024 class. */ +export const WAN_LEGACY_RATIO_MAP: Record = { + "1:1": "1024*1024", + "16:9": "1280*720", + "9:16": "720*1280", + "3:4": "768*1152", + "4:3": "1152*768", +}; + +/** wanx-v1 only documents these discrete sizes. */ +export const WANX_V1_RATIO_MAP: Record = { + "1:1": "1024*1024", + "9:16": "720*1280", + "3:4": "768*1152", + "16:9": "1280*720", +}; + +/** wan2.5-i2i — total pixels up to ~1280*1280. */ +export const WAN25_I2I_RATIO_MAP: Record = { + "1:1": "1280*1280", + "16:9": "1280*720", + "9:16": "720*1280", + "4:3": "1152*864", + "3:4": "864*1152", +}; + +/** @deprecated Prefer profile maps; kept for callers that still think in sync/async. */ +export const SYNC_RATIO_MAP = QWEN_IMAGE_20_RATIO_MAP; +/** @deprecated Prefer profile maps; kept as qwen-image-plus/max fixed presets. */ +export const ASYNC_RATIO_MAP = QWEN_IMAGE_FIXED_RATIO_MAP; + +const PROFILE_RATIO_MAPS: Record> = { + "qwen-image-2.0": QWEN_IMAGE_20_RATIO_MAP, + "qwen-image-fixed": QWEN_IMAGE_FIXED_RATIO_MAP, + wan27: WAN27_RATIO_MAP, + "z-image": Z_IMAGE_RATIO_MAP, + wan26: WAN26_RATIO_MAP, + "wan-legacy": WAN_LEGACY_RATIO_MAP, + "wanx-v1": WANX_V1_RATIO_MAP, + "wan25-i2i": WAN25_I2I_RATIO_MAP, +}; + +/** Resolve `--size` with an explicit model size profile. */ +export function resolveImageSize(input: string, sizeProfile: ImageSizeProfile): string; +export function resolveImageSize( + input: string | undefined, + sizeProfile: ImageSizeProfile, +): string | undefined; +/** @deprecated Prefer `ImageSizeProfile`; boolean maps sync→qwen-image-2.0 / async→qwen-image-fixed. */ export function resolveImageSize(input: string, useSync: boolean): string; export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined; -export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined { +export function resolveImageSize( + input: string | undefined, + sizeProfileOrUseSync: ImageSizeProfile | boolean, +): string | undefined { if (!input) return undefined; - const map = useSync ? SYNC_RATIO_MAP : ASYNC_RATIO_MAP; + const profile: ImageSizeProfile = + typeof sizeProfileOrUseSync === "boolean" + ? sizeProfileOrUseSync + ? "qwen-image-2.0" + : "qwen-image-fixed" + : sizeProfileOrUseSync; + const map = PROFILE_RATIO_MAPS[profile]; return map[input] ?? input; } diff --git a/packages/runtime/tests/image-size.test.ts b/packages/runtime/tests/image-size.test.ts new file mode 100644 index 00000000..b57df077 --- /dev/null +++ b/packages/runtime/tests/image-size.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "vite-plus/test"; +import { resolveImageSize } from "../src/utils/image-size.ts"; + +test("qwen-image-plus fixed profile maps 1:1 to 1328*1328", () => { + expect(resolveImageSize("1:1", "qwen-image-fixed")).toBe("1328*1328"); + expect(resolveImageSize("16:9", "qwen-image-fixed")).toBe("1664*928"); +}); + +test("qwen-image-2.0 profile maps 1:1 to 2048*2048", () => { + expect(resolveImageSize("1:1", "qwen-image-2.0")).toBe("2048*2048"); +}); + +test("wanx-v1 profile maps 1:1 to 1024*1024", () => { + expect(resolveImageSize("1:1", "wanx-v1")).toBe("1024*1024"); + expect(resolveImageSize("16:9", "wanx-v1")).toBe("1280*720"); +}); + +test("wan2.5-i2i profile maps 1:1 to 1280*1280", () => { + expect(resolveImageSize("1:1", "wan25-i2i")).toBe("1280*1280"); +}); + +test("z-image profile maps 1:1 to 1024*1024", () => { + expect(resolveImageSize("1:1", "z-image")).toBe("1024*1024"); +}); + +test("pixel sizes pass through unchanged", () => { + expect(resolveImageSize("1024*1024", "qwen-image-fixed")).toBe("1024*1024"); +}); diff --git a/skills/bailian-cli/reference/image.md b/skills/bailian-cli/reference/image.md index e3bdd92a..9b9a1f23 100644 --- a/skills/bailian-cli/reference/image.md +++ b/skills/bailian-cli/reference/image.md @@ -24,24 +24,25 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------- | -------- | ----------------------------------------------------------------------- | -| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | -| `--prompt ` | string | yes | Edit instruction text | -| `--model ` | string | no | Model ID (default: qwen-image-2.0) | -| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | -| `--n ` | number | no | Number of images (default: 1, max: 6) | -| `--seed ` | number | no | Random seed for reproducible results | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--out-dir ` | string | no | Download images to directory | -| `--out-prefix ` | string | no | Filename prefix (default: edited) | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------- | +| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | +| `--prompt ` | string | yes | Edit instruction text | +| `--model ` | string | no | Model ID (default: qwen-image-2.0) | +| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | +| `--n ` | number | no | Number of images (default: 1, max: 6) | +| `--seed ` | number | no | Random seed for reproducible results | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--function ` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--out-dir ` | string | no | Download images to directory | +| `--out-prefix ` | string | no | Filename prefix (default: edited) | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -69,6 +70,10 @@ bl image edit --image ./photo.png --prompt "Change the style" --model wan2.7-ima bl image edit --image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview ``` +```bash +bl image edit --image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all +``` + ```bash bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false ``` From 58252911a874373db927ff6481cbd1716ba08c8a Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 27 Jul 2026 21:09:32 +0800 Subject: [PATCH 3/3] fix(image): correct wan2.5/2.6 size presets and wanx-v1 dated aliases --- packages/core/src/client/image-routes.ts | 9 +++++++-- packages/core/tests/image-routes.test.ts | 9 +++++++++ packages/runtime/src/utils/image-size.ts | 6 +++--- packages/runtime/tests/image-size.test.ts | 6 ++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/core/src/client/image-routes.ts b/packages/core/src/client/image-routes.ts index 59c28953..2c532e8c 100644 --- a/packages/core/src/client/image-routes.ts +++ b/packages/core/src/client/image-routes.ts @@ -83,12 +83,17 @@ function isSyncEditModel(model: string): boolean { return startsWithAny(model, SYNC_EDIT_PREFIXES); } +/** wanx-v1 and dated aliases (e.g. wanx-v1-0521) use the legacy text2image API. */ +function isWanxV1Model(model: string): boolean { + return /^wanx-v1(?:-|$)/i.test(model); +} + /** wan2.5 / wan2.2 / wan2.1 / wanx text-to-image models use the legacy prompt API. */ export function isLegacyText2ImageModel(model: string): boolean { if (model.startsWith("wan2.6-t2i") || model.startsWith("wan2.6-image")) return false; if (isSyncGenerateModel(model)) return false; if (/^wan2\.[0-5][^-]*-t2i/i.test(model)) return true; - if (/^wanx-v1$/i.test(model)) return true; + if (isWanxV1Model(model)) return true; if (/^wanx/i.test(model) && /t2i|text2image/i.test(model)) return true; return false; } @@ -120,7 +125,7 @@ export function resolveImageSizeProfile(model: string): ImageSizeProfile { ) { return "wan26"; } - if (/^wanx-v1$/i.test(model)) return "wanx-v1"; + if (isWanxV1Model(model)) return "wanx-v1"; if (/wan2\.5-i2i/i.test(model)) return "wan25-i2i"; if (isLegacyText2ImageModel(model)) return "wan-legacy"; return "wan26"; diff --git a/packages/core/tests/image-routes.test.ts b/packages/core/tests/image-routes.test.ts index 8c827010..73e27f88 100644 --- a/packages/core/tests/image-routes.test.ts +++ b/packages/core/tests/image-routes.test.ts @@ -27,6 +27,7 @@ test("legacy text2image covers wan2.5/2.2/2.1 t2i and wanx but not wan2.6-t2i/im expect(isLegacyText2ImageModel("wan2.1-t2i-turbo")).toBe(true); expect(isLegacyText2ImageModel("wanx2.0-t2i-turbo")).toBe(true); expect(isLegacyText2ImageModel("wanx-v1")).toBe(true); + expect(isLegacyText2ImageModel("wanx-v1-0521")).toBe(true); expect(isLegacyText2ImageModel("wan2.6-t2i")).toBe(false); expect(isLegacyText2ImageModel("wan2.6-image")).toBe(false); expect(isLegacyText2ImageModel("wan2.7-image")).toBe(false); @@ -48,6 +49,7 @@ test("size profiles are model-specific, not sync/async", () => { expect(resolveImageSizeProfile("z-image-turbo")).toBe("z-image"); expect(resolveImageSizeProfile("wan2.6-t2i")).toBe("wan26"); expect(resolveImageSizeProfile("wanx-v1")).toBe("wanx-v1"); + expect(resolveImageSizeProfile("wanx-v1-0521")).toBe("wanx-v1"); expect(resolveImageSizeProfile("wan2.5-i2i-preview")).toBe("wan25-i2i"); expect(resolveImageSizeProfile("wan2.2-t2i-plus")).toBe("wan-legacy"); }); @@ -72,6 +74,13 @@ test("resolveImageGenerateApi picks path, input style, and size profile", () => sizeProfile: "wanx-v1", inputStyle: "prompt", }); + expect(resolveImageGenerateApi("wanx-v1-0521")).toMatchObject({ + kind: "async-text2image", + path: "/api/v1/services/aigc/text2image/image-synthesis", + inputStyle: "prompt", + useSync: false, + sizeProfile: "wanx-v1", + }); expect(resolveImageGenerateApi("wan2.6-t2i")).toMatchObject({ kind: "async-image-generation", sizeProfile: "wan26", diff --git a/packages/runtime/src/utils/image-size.ts b/packages/runtime/src/utils/image-size.ts index 39590dc4..d2fd1f7e 100644 --- a/packages/runtime/src/utils/image-size.ts +++ b/packages/runtime/src/utils/image-size.ts @@ -46,11 +46,11 @@ export const Z_IMAGE_RATIO_MAP: Record = { "2:3": "832*1248", }; -/** wan2.6-t2i / wan2.6-image / wan2.5-t2i — ~1280 class. */ +/** wan2.6-t2i / wan2.6-image / wan2.5-t2i — total pixels ≥ 1280*1280. */ export const WAN26_RATIO_MAP: Record = { "1:1": "1280*1280", - "16:9": "1280*720", - "9:16": "720*1280", + "16:9": "1696*960", + "9:16": "960*1696", "4:3": "1472*1104", "3:4": "1104*1472", }; diff --git a/packages/runtime/tests/image-size.test.ts b/packages/runtime/tests/image-size.test.ts index b57df077..d5ae183c 100644 --- a/packages/runtime/tests/image-size.test.ts +++ b/packages/runtime/tests/image-size.test.ts @@ -15,6 +15,12 @@ test("wanx-v1 profile maps 1:1 to 1024*1024", () => { expect(resolveImageSize("16:9", "wanx-v1")).toBe("1280*720"); }); +test("wan26 profile maps 16:9 and 9:16 to ≥1280*1280 total pixels", () => { + expect(resolveImageSize("16:9", "wan26")).toBe("1696*960"); + expect(resolveImageSize("9:16", "wan26")).toBe("960*1696"); + expect(resolveImageSize("1:1", "wan26")).toBe("1280*1280"); +}); + test("wan2.5-i2i profile maps 1:1 to 1280*1280", () => { expect(resolveImageSize("1:1", "wan25-i2i")).toBe("1280*1280"); });