From 9a1370039081f151da01346d08365c354c733485 Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 27 Jul 2026 10:16:25 +0800 Subject: [PATCH] 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 a6996e4..c4a1d08 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 00bd7f5..6a7db1b 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 0a5cab6..4d42c04 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 55369ae..119c7bd 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 0000000..b16fb2b --- /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 536451d..0e47cbf 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 3fa0092..3d17ea1 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 0000000..fe05a39 --- /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 48e32f6..add280b 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 84419f8..e3bdd92 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 ```