From 1aa539b59318634e8351f60e74a504fc852729d8 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:22:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=B1=80=E9=83=A8=E9=87=8D?= =?UTF-8?q?=E7=BB=98=E3=80=81=E5=9B=BE=E7=89=87=E8=A7=84=E6=A0=BC=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8F=82=E6=95=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/model_capability.go | 12 ++++++++-- backend/internal/service/model_router.go | 7 ++++++ backend/internal/service/model_router_test.go | 13 +++++++++++ .../canvas-drawing-excalidraw-editor.tsx | 20 ++++++++-------- .../canvas/canvas-node-mask-edit-dialog.tsx | 20 ++++------------ web/src/components/image-settings-panel.tsx | 8 ++++--- .../components/image-size-presets-editor.tsx | 12 ++++++---- web/src/lib/image-size-presets.ts | 4 +++- web/src/lib/model-capabilities.ts | 9 +++++++- .../canvas/canvas-project-media-dialogs.tsx | 2 +- .../pages/canvas/use-canvas-media-tools.ts | 23 +++++++++++++++++-- web/src/services/api/generation-task.ts | 21 ++++++++++++++--- web/test/image-size-presets.test.tsx | 18 +++++++++++++-- 13 files changed, 124 insertions(+), 45 deletions(-) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 8c8457876..e32347697 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -822,10 +822,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return BadAuthRequest(err.Error()) } } - if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { + quality := strings.TrimSpace(input.Config.Quality) + if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } - if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { return err } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) @@ -835,6 +836,13 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func qualityForImageValidation(quality string, fallback string) string { + if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") { + return fallback + } + return firstNonEmpty(quality, fallback) +} + func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { return nil diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index f37e82b2a..49fdc52f3 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -65,6 +65,13 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper explicitOptions = true for key, value := range options { name := canonicalCapabilityOptionName(key) + // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 + if name == "quality" { + if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { + continue + } + } intent.Options[name] = normalizeModelRequestOption(name, value) } } diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go index d2efcddde..4cea97089 100644 --- a/backend/internal/service/model_router_test.go +++ b/backend/internal/service/model_router_test.go @@ -38,6 +38,19 @@ func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) { } } +func TestAutoQualityIsOmittedFromCapabilityIntent(t *testing.T) { + intent := ModelRequestIntentFromTaskInput(map[string]any{ + "mode": "image", + "capabilityOptions": map[string]any{"quality": "auto", "size": "2048x878"}, + }, "canvas_image", "image") + if _, exists := intent.Options["quality"]; exists { + t.Fatalf("quality option = %#v, want omitted for auto", intent.Options["quality"]) + } + if intent.Options["size"] != "2048x878" { + t.Fatalf("size option = %#v, want preserved", intent.Options["size"]) + } +} + func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { input := map[string]any{ "mode": "video", diff --git a/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx b/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx index 8c362468f..cb60da19f 100644 --- a/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx +++ b/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx @@ -49,16 +49,16 @@ export const CanvasDrawingExcalidrawEditor = forwardRef { - apiRef.current = api; - api.setActiveTool({ type: "freedraw" }); - onReady(); - }} - UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }} + initialData={initialData} + langCode="zh-CN" + theme={colorScheme} + autoFocus + excalidrawAPI={(api) => { + apiRef.current = api; + api.setActiveTool({ type: "freedraw" }); + onReady(); + }} + UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }} /> ); }); diff --git a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx index 10cd82797..915f35c9b 100644 --- a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx +++ b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx @@ -41,10 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon setBrushSize(defaultBrushSize); setMode("paint"); setError(""); - setAdvancedOpen(false); + setAdvancedOpen(true); setGenerationConfig(config); void readImageMeta(dataUrl).then(setImage); - }, [config, dataUrl, open]); + }, [dataUrl, open]); useEffect(() => { clearCanvas(maskCanvasRef.current); @@ -115,7 +115,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
- + {image ? ( <> @@ -123,7 +123,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon ref={previewCanvasRef} width={image.width} height={image.height} - className="absolute inset-0 h-full w-full cursor-crosshair touch-none" + className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none" onPointerDown={startDraw} onPointerMove={moveDraw} onPointerUp={stopDraw} @@ -173,17 +173,6 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon {error ?
{error}
: null}
- setGenerationConfig((current) => ({ ...current, [key]: value }))} - /> -
- + toggleTier(tier, ratios, enabled)} />
diff --git a/web/src/lib/image-size-presets.ts b/web/src/lib/image-size-presets.ts index cfc1bfcfb..f7c951e30 100644 --- a/web/src/lib/image-size-presets.ts +++ b/web/src/lib/image-size-presets.ts @@ -2,7 +2,7 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso import type { ImageCapabilityConfig } from "./model-capabilities"; export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"]; -export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"]; +export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"]; const standardSizes: Record = { "1:1": ["1024x1024", "2048x2048", "2880x2880"], "16:9": ["1824x1024", "2752x1536", "3840x2160"], @@ -11,6 +11,8 @@ const standardSizes: Record = { "3:4": ["1024x1360", "1728x2304", "2448x3264"], "3:2": ["1536x1024", "2496x1664", "3504x2336"], "2:3": ["1024x1536", "1664x2496", "2336x3504"], + "4:5": ["1024x1280", "1792x2240", "2560x3200"], + "5:4": ["1280x1024", "2240x1792", "3200x2560"], "21:9": ["2048x878", "3136x1344", "3808x1632"], }; diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 0c4140db6..ca5854ec9 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -896,7 +896,14 @@ function matchWorkflowValue(value: string, options: string[]) { export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) { const size = normalizeImageSizeSetting(profile, value.size); - const quality = profile.quality.supported ? (value.quality && profile.quality.values.includes(value.quality) ? value.quality : profile.quality.default || "auto") : profile.quality.default || "auto"; + const requestedQuality = String(value.quality || "").trim().toLowerCase(); + const quality = profile.quality.supported + ? requestedQuality === "auto" || requestedQuality === "any" + ? "auto" + : value.quality && profile.quality.values.includes(value.quality) + ? value.quality + : profile.quality.default || "auto" + : profile.quality.default || "auto"; const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1)))); const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false"; return { size, quality, count, transparentBackground }; diff --git a/web/src/pages/canvas/canvas-project-media-dialogs.tsx b/web/src/pages/canvas/canvas-project-media-dialogs.tsx index 14f1b5877..2be17fde8 100644 --- a/web/src/pages/canvas/canvas-project-media-dialogs.tsx +++ b/web/src/pages/canvas/canvas-project-media-dialogs.tsx @@ -47,7 +47,7 @@ export function CanvasProjectMediaDialogs({ <> {cropNode?.metadata?.content ? onCrop(cropNode, crop)} /> : null} {annotationNode?.metadata?.content ? onAnnotate(annotationNode, dataUrl)} /> : null} - {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} + {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} {splitNode?.metadata?.content ? onSplit(splitNode, params)} /> : null} {upscaleNode?.metadata?.content ? onUpscale(upscaleNode, params)} /> : null} diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 326041ea2..3e07aacf4 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -36,12 +36,22 @@ import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video- import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment"; import { generationErrorMessage } from "@/lib/generation-error"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { defaultImageParamsForModel } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedVideo } from "@/services/api/video"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import type { GenerationTask } from "@/services/api/task-center"; + +function normalizeMaskEditQuality(quality: string | undefined, size: string | undefined) { + const value = String(quality || "").trim().toLowerCase(); + if (value && value !== "auto" && value !== "any") return value; + const match = String(size || "").trim().toLowerCase().match(/^(\d+)x(\d+)$/); + if (!match) return quality || "auto"; + const pixels = Number(match[1]) * Number(match[2]); + return pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : quality || "auto"; +} import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas"; import type { StartCanvasUploadStatus } from "./use-canvas-upload"; @@ -576,13 +586,22 @@ export function useCanvasMediaTools({ const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { if (!node.metadata?.content) return; const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image"); + const selectedModel = payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model; + const modelDefaults = defaultImageParamsForModel(baseGenerationConfig, selectedModel); + const selectedImageProfile = modelCapabilityConfigFor(baseGenerationConfig, selectedModel).image; + if (!selectedImageProfile?.references.maskSupported) { + message.error("当前图片模型不支持局部重绘蒙版,请选择支持蒙版编辑的模型"); + return; + } const generationConfig = { ...baseGenerationConfig, ...payload.generationConfig, - model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model, + model: selectedModel, imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel, + quality: normalizeMaskEditQuality(payload.generationConfig?.quality || node.metadata?.quality || baseGenerationConfig.quality || modelDefaults.quality, payload.generationConfig?.size || node.metadata?.size || baseGenerationConfig.size || modelDefaults.size), count: String(payload.generationConfig?.count || 1), - size: payload.generationConfig?.size || node.metadata?.size || "auto", + // 原图像素尺寸不是模型的输出尺寸合同;非高级设置时使用模型默认尺寸,避免把节点尺寸误发给上游。 + size: payload.generationConfig?.size || node.metadata?.size || modelDefaults.size, }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index 688ae6b51..5d81896c3 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -538,7 +538,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM if (workflow) return workflowProviderConfig(config, requestConfig, workflow); const generationOptions = { size: config.size, - quality: config.quality, + quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground, count: config.count, videoSeconds: config.videoSeconds, @@ -623,13 +623,28 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode) const channel = resolveModelChannel(config, config.model); const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec; const candidates: Record = mode === "image" - ? { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) } + ? { size: config.size, quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground === "true", count: Number(config.count) } : mode === "video" ? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" } : mode === "audio" ? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) } : {}; - return Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key]))); + const filtered = Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key]))); + // 图片质量和画幅同时参与按规格计费匹配。即使逻辑模型能力声明只把其中一项 + // 暴露给供应线路,报价仍需要看到客户端最终选择,避免局部重绘等编辑请求落到 + // “未配置所选规格”的错误分支。 + if (mode === "image") { + for (const key of ["quality", "size"] as const) { + const value = candidates[key]; + if (value !== undefined && value !== null && String(value).trim() !== "") filtered[key] = value; + } + } + return filtered; +} + +function omittedImageQuality(value: string | undefined) { + const normalized = String(value || "").trim().toLowerCase(); + return normalized === "auto" || normalized === "any" ? undefined : value; } export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult { diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index 1c707980d..dd3a188d0 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -42,6 +42,17 @@ describe("统一图片分辨率与宽高比", () => { } }); + test("固定比例编辑器包含 4:5 和 5:4 三档尺寸", () => { + expect(IMAGE_RATIOS).toContain("4:5"); + expect(IMAGE_RATIOS).toContain("5:4"); + expect(imagePresetForRatio("1k", "4:5").size).toBe("1024x1280"); + expect(imagePresetForRatio("2k", "4:5").size).toBe("1792x2240"); + expect(imagePresetForRatio("4k", "4:5").size).toBe("2560x3200"); + expect(imagePresetForRatio("1k", "5:4").size).toBe("1280x1024"); + expect(imagePresetForRatio("2k", "5:4").size).toBe("2240x1792"); + expect(imagePresetForRatio("4k", "5:4").size).toBe("3200x2560"); + }); + test("管理员设置同步支持值、默认值,并保持精确尺寸", () => { const profile = defaultImageCapabilityConfig(); profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false }; @@ -139,8 +150,11 @@ describe("统一图片分辨率与宽高比", () => { expect(editor).toContain(`aria-label="${tier} 16:9"`); } expect(editor).toContain('aria-label="默认输出"'); - expect(editor).toContain("全不选"); - expect(editor).toContain("全选"); + expect(editor).not.toContain("全不选"); + expect(editor).not.toContain("全选"); + expect(editor).toContain('aria-label="启用 1K 规格"'); + expect(editor).toContain('aria-label="启用 2K 规格"'); + expect(editor).toContain('aria-label="启用 4K 规格"'); const readOnly = renderToStaticMarkup( {}} />); const buttons = [...readOnly.matchAll(/]*>/g)]; expect(buttons.length).toBeGreaterThan(24);