diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index c4a1d08..95d5054 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 6a7db1b..31eaab5 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 7476a8d..3b405fa 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 4d42c04..913567e 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 b16fb2b..59c2895 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 0e47cbf..cb06c21 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 3d17ea1..c6c00ec 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 fe05a39..8c82701 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, - }); - expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ - kind: "sync-multimodal", - useSync: true, + sizeProfile: "qwen-image-2.0", + promptExtendDefault: true, }); expect(resolveImageGenerateApi("qwen-image-plus")).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("qwen-image")).toMatchObject({ kind: "sync-multimodal", - useSync: true, + sizeProfile: "qwen-image-fixed", + }); + expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ + kind: "sync-multimodal", + 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, - }); - expect(resolveImageEditApi("wan2.6-image")).toMatchObject({ +test("resolveImageEditApi excludes pure T2I models from sync edit", () => { + expect(resolveImageEditApi("wan2.7-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("wan2.6-image")).toMatchObject({ kind: "sync-multimodal", useSync: true, }); + 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 add280b..f69f2c1 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 b986b5c..39590dc 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. + * Resolve image `--size` for generate/edit by model-family profile. * - * 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. - * - * 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 0000000..b57df07 --- /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 e3bdd92..9b9a1f2 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 ```