diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 02cdc4a1b..37c83d80f 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -178,7 +178,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User if idErr != nil { return nil, idErr } - missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) + missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) } added, err := s.repo.CreateMissingChannelModels(missing) if err != nil { @@ -249,7 +249,8 @@ func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.Use } known := make(map[string]struct{}, len(existing)) for _, item := range existing { - known[channelModelCatalogKey(item.ModelKey)] = struct{}{} + providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey) + known[channelModelCatalogKey(providerKey)] = struct{}{} } retired := retiredChannelModelKeys(channel.RetiredModelsJSON) missing := make([]model.ChannelModel, 0, len(chosen)) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 8a723bc42..7bcfee9b2 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -846,10 +846,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)) @@ -859,6 +860,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 017783066..e77bdb4c0 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/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin index 5adb0bce3..65e9a08b5 100644 Binary files a/plugin-packages/autodl-comfyui.yingce-plugin and b/plugin-packages/autodl-comfyui.yingce-plugin differ 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-image-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index 890380cb7..a4ce12c00 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { Camera, Settings2 } from "lucide-react"; -import { Button, Modal } from "antd"; +import { Button } from "antd"; import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel"; import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog"; +import { AppModal } from "@/components/ui/product/app-modal/app-modal"; import { canvasThemes } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; @@ -103,7 +104,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang )} {panel} {cameraOpen && onCameraControlChange && ( - setCameraOpen(false)} styles={{ body: { padding: 0 } }}> + setCameraOpen(false)}> setCameraOpen(false)} @@ -112,7 +113,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang setCameraOpen(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/components/layout/system-announcement-center.tsx b/web/src/components/layout/system-announcement-center.tsx index d8ac370c9..0ff6c12cd 100644 --- a/web/src/components/layout/system-announcement-center.tsx +++ b/web/src/components/layout/system-announcement-center.tsx @@ -12,6 +12,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/ const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000; const ANNOUNCEMENT_CACHE_TTL_MS = 60_000; const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today"; +const ANNOUNCEMENT_DISMISS_SESSION_PREFIX = "yingce.announcements.dismiss-session"; type AnnouncementFeed = Awaited>; @@ -46,9 +47,9 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : ""; useEffect(() => { - if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return; + if (!autoOpen || open || !feedQuery.isSuccess || announcements.length === 0) return; const fingerprint = announcementFeedFingerprint(announcements); - if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return; + if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId, fingerprint)) return; setAutomaticPrompt(true); setOpen(true); }, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]); @@ -71,7 +72,8 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const dismissAutomaticPrompt = (duration: "once" | "today") => { const fingerprint = announcementFeedFingerprint(announcements); if (fingerprint) setDismissedFingerprint(fingerprint); - if (duration === "today") rememberAnnouncementDismissalToday(userId); + if (fingerprint) rememberAnnouncementDismissalSession(userId, fingerprint); + if (duration === "today") rememberAnnouncementDismissalToday(userId, fingerprint); setAutomaticPrompt(false); setOpen(false); }; @@ -138,17 +140,36 @@ function announcementFeedFingerprint(announcements: AnnouncementFeed["announceme .join("|"); } -function announcementAutoPromptSuppressed(userId: string) { +function announcementAutoPromptSuppressed(userId: string, fingerprint: string) { + if (hasAnnouncementDismissalSession(userId, fingerprint)) return true; try { - return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey(); + const value = localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`); + const parsed = value ? JSON.parse(value) as { date?: string; fingerprint?: string } : null; + return parsed?.date === localDateKey() && parsed.fingerprint === fingerprint; } catch { return false; } } -function rememberAnnouncementDismissalToday(userId: string) { +function hasAnnouncementDismissalSession(userId: string, fingerprint: string) { try { - localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey()); + return sessionStorage.getItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`) === fingerprint; + } catch { + return false; + } +} + +function rememberAnnouncementDismissalSession(userId: string, fingerprint: string) { + try { + sessionStorage.setItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`, fingerprint); + } catch { + // sessionStorage 不可用时保留页内 dismissedFingerprint 作为降级。 + } +} + +function rememberAnnouncementDismissalToday(userId: string, fingerprint: string) { + try { + localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, JSON.stringify({ date: localDateKey(), fingerprint })); } catch { // 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。 } 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/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index e78d71e32..1b7b57e39 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -231,7 +231,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: return true; }); const pagedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize); - const existingFetchModelKeys = new Set(items.map((item) => normalizeFetchModelKey(item.modelKey))); + const existingFetchModelKeys = new Set(items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean).map(normalizeFetchModelKey))); const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name))); const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length; const allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name)); 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 b703b3a53..e2f94d8a4 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -38,12 +38,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"; @@ -573,13 +583,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 5da06aa4e..a6e438ea0 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -579,7 +579,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, @@ -664,13 +664,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/generation-task-local.test.ts b/web/test/generation-task-local.test.ts index 08582a352..e3ecbcc69 100644 --- a/web/test/generation-task-local.test.ts +++ b/web/test/generation-task-local.test.ts @@ -1387,7 +1387,8 @@ test("remote provider keeps Create resolution semantics and still creates one Ba }, ); - expect(backendInput?.input.config).toMatchObject({ vquality: "720", quality: "auto" }); + expect(backendInput?.input.config).toMatchObject({ vquality: "720" }); + expect(backendInput?.input.config.quality).toBeUndefined(); expect(localCalls).toBe(0); }); 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);