diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index e73f2489a..21477784a 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -7,8 +7,8 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities"; -import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -50,8 +50,20 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC capability: mode, input: inputSummary, videoOperation: node.metadata?.videoEditOperation, - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, - options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed }, mode), + videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, + options: modelRequestOptions({ + ...globalConfig, + size: node.metadata?.size || globalConfig.size, + quality: node.metadata?.quality || globalConfig.quality, + count: String(node.metadata?.count || globalConfig.count), + videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, + vquality: node.metadata?.vquality || globalConfig.vquality, + videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, + videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, + audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, + audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, + audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, + }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; @@ -229,25 +241,51 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const storedModel = node.metadata?.model; const preferredModel = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel; const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel; - const imageProfile = mode === "image" ? modelCapabilityConfigFor(globalConfig, model).image! : undefined; - const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined; + const generationDefaults = resolveModelGenerationDefaults( + globalConfig, + model, + mode === "image" ? "image" : mode === "video" ? "video" : undefined, + mode === "image" + ? { + size: node.metadata?.size, + quality: node.metadata?.quality, + transparentBackground: node.metadata?.transparentBackground, + count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count), + } + : { + size: node.metadata?.size, + videoSeconds: node.metadata?.seconds, + vquality: node.metadata?.vquality, + videoGenerateAudio: node.metadata?.generateAudio, + videoWatermark: node.metadata?.watermark, + }, + { + size: globalConfig.size || defaultConfig.size, + quality: globalConfig.quality || defaultConfig.quality, + transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground, + count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count), + videoSeconds: globalConfig.videoSeconds || defaultConfig.videoSeconds, + vquality: globalConfig.vquality || defaultConfig.vquality, + videoGenerateAudio: globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, + videoWatermark: globalConfig.videoWatermark || defaultConfig.videoWatermark, + }, + ); const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined; - const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined; return { ...globalConfig, model, - quality: normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality, - size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size, - transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"), - videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds), - vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality), - videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false", - videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false", + quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality, + size: generationDefaults.size || globalConfig.size || defaultConfig.size, + transparentBackground: generationDefaults.transparentBackground || "false", + videoSeconds: generationDefaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), + vquality: generationDefaults.vquality || normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality), + videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false", + videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark || String(videoProfile.watermark.default) : "false", audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions, - count: normalizedImage?.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), + count: generationDefaults.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), }; } diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 602f4e491..ad9606720 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -8,7 +8,7 @@ import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-genera import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelRequestOptions, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; +import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -77,8 +77,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi characterCount: activeReferences.filter((item) => item.kind === "character").length, }, videoOperation: node.metadata?.videoEditOperation, - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, - options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed }, mode), + videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, + options: modelRequestOptions({ + ...globalConfig, + size: node.metadata?.size || globalConfig.size, + quality: node.metadata?.quality || globalConfig.quality, + count: String(node.metadata?.count || globalConfig.count), + videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, + vquality: node.metadata?.vquality || globalConfig.vquality, + videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, + videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, + audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, + audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, + audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, + }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); @@ -548,21 +560,50 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel; const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel; + const defaults = resolveModelGenerationDefaults( + globalConfig, + model, + mode === "image" ? "image" : mode === "video" ? "video" : undefined, + mode === "image" + ? { + size: node.metadata?.size, + quality: node.metadata?.quality, + transparentBackground: node.metadata?.transparentBackground, + count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count), + } + : { + size: node.metadata?.size, + videoSeconds: node.metadata?.seconds, + vquality: node.metadata?.vquality, + videoGenerateAudio: node.metadata?.generateAudio, + videoWatermark: node.metadata?.watermark, + }, + { + size: globalConfig.size || defaultConfig.size, + quality: globalConfig.quality || defaultConfig.quality, + transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground, + count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count), + videoSeconds: globalConfig.videoSeconds || defaultConfig.videoSeconds, + vquality: globalConfig.vquality || defaultConfig.vquality, + videoGenerateAudio: globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, + videoWatermark: globalConfig.videoWatermark || defaultConfig.videoWatermark, + }, + ); return { ...globalConfig, model, - quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, - size: node.metadata?.size || globalConfig.size || defaultConfig.size, - transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false", - videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds), - vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality), - videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, - videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark, + quality: defaults.quality || globalConfig.quality || defaultConfig.quality, + size: defaults.size || globalConfig.size || defaultConfig.size, + transparentBackground: defaults.transparentBackground || "false", + videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), + vquality: defaults.vquality || normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality), + videoGenerateAudio: defaults.videoGenerateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, + videoWatermark: defaults.videoWatermark || globalConfig.videoWatermark || defaultConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions, - count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), + count: defaults.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), }; } diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index f4db0c10b..cc95561c1 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -7,8 +7,8 @@ import { resourceIdFromStorageKey } from "@/services/api/resources"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { isSeedanceVideoConfig } from "@/lib/seedance-video"; -import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; -import { modelRequestOptions, resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection"; import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; @@ -399,22 +399,46 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } : { capability: mode, options: liveOptions }; const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel; - const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined; - const normalizedImage = imageProfile - ? normalizeImageValue(imageProfile, { - quality: requestedConfig.quality, - size: requestedConfig.size, - transparentBackground: requestedConfig.transparentBackground, - count: requestedConfig.count, - }) - : undefined; + const generationDefaults = resolveModelGenerationDefaults( + config, + model, + mode === "image" ? "image" : mode === "video" ? "video" : undefined, + mode === "image" + ? { + size: node?.metadata?.size, + quality: node?.metadata?.quality, + transparentBackground: node?.metadata?.transparentBackground, + count: requestedConfig.count, + } + : { + size: node?.metadata?.size, + videoSeconds: node?.metadata?.seconds, + vquality: node?.metadata?.vquality, + videoGenerateAudio: node?.metadata?.generateAudio, + videoWatermark: node?.metadata?.watermark, + }, + { + size: requestedConfig.size, + quality: requestedConfig.quality, + transparentBackground: requestedConfig.transparentBackground, + count: requestedConfig.count, + videoSeconds: requestedConfig.videoSeconds, + vquality: requestedConfig.vquality, + videoGenerateAudio: requestedConfig.videoGenerateAudio, + videoWatermark: requestedConfig.videoWatermark, + }, + ); return { ...requestedConfig, model, - quality: normalizedImage?.quality || requestedConfig.quality, - size: normalizedImage?.size || requestedConfig.size, - transparentBackground: normalizedImage?.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"), - count: normalizedImage?.count || requestedConfig.count, + quality: generationDefaults.quality || requestedConfig.quality, + size: generationDefaults.size || requestedConfig.size, + transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"), + videoSeconds: generationDefaults.videoSeconds || requestedConfig.videoSeconds, + vquality: generationDefaults.vquality || requestedConfig.vquality, + videoGenerateAudio: generationDefaults.videoGenerateAudio || requestedConfig.videoGenerateAudio, + videoWatermark: generationDefaults.videoWatermark || requestedConfig.videoWatermark, + count: generationDefaults.count || requestedConfig.count, }; } diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index dbc6c1aec..b0d0a7c91 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -import { defaultImageCapabilityConfig, modelCapabilityConfigFor, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; export type ModelInputSummary = { @@ -129,7 +129,13 @@ function logicalModelCompatibilityError(spec: NonNullable; + +export function resolveModelGenerationDefaults( + config: AiConfig, + model: string, + capability: "image" | "video" | undefined, + explicit: Partial = {}, + fallback: Partial = {}, +): Partial { + if (!capability) return {}; + const channel = resolveModelChannel(config, model); + const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); + const isManagedModel = Boolean(cost?.logicalModelId || cost?.logicalCapabilitySpec); + const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel ? undefined : fallback[key]); + const profile = modelCapabilityConfigFor(config, model); + + if (capability === "image" && profile.image) { + const normalized = normalizeImageValue(profile.image, { + size: source("size"), + quality: source("quality"), + count: source("count"), + transparentBackground: source("transparentBackground"), + }); + return normalized; + } + + if (capability === "video" && profile.video) { + const normalized = normalizeVideoValue(profile.video, { + seconds: source("videoSeconds"), + ratio: source("size"), + resolution: source("vquality"), + }); + return { + videoSeconds: normalized.seconds, + size: normalized.ratio, + vquality: normalized.resolution.replace(/p$/i, ""), + videoGenerateAudio: source("videoGenerateAudio") ?? String(profile.video.generateAudio.default), + videoWatermark: source("videoWatermark") ?? String(profile.video.watermark.default), + }; + } + + return {}; +} + export function maxModelInputCapacity(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio") { const options = selectableModelsByCapability(config, capability); if (!options.length) return null; diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 27dddce97..f5b2eb579 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -175,7 +175,7 @@ export default function CreatePage() { audioCount: attachments.filter((attachment) => creationAttachmentKind(attachment) === "audio").length, characterCount: 0, }, - videoSeconds: seconds, + videoSeconds: mode === "video" ? seconds : undefined, imageSize: mode === "image" ? ratio : undefined, options: mode === "image" ? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" } @@ -497,7 +497,20 @@ export default function CreatePage() { const controller = new AbortController(); const requestLifecycle = beginGenerationConsumer(controller.signal); abortRef.current = controller; - const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count }; + const normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined; + const normalizedVideo = mode === "video" ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : undefined; + const requestConfig = { + ...config, + model: selectedModel, + imageModel: selectedModel, + videoModel: selectedModel, + textModel: selectedModel, + ...(mode === "image" + ? { size: normalizedImage?.size || ratio, quality: normalizedImage?.quality || quality, count: normalizedImage?.count || count, videoSeconds: config.videoSeconds } + : mode === "video" + ? { size: normalizedVideo?.ratio || ratio, videoSeconds: normalizedVideo?.seconds || seconds, vquality: (normalizedVideo?.resolution || videoQuality).replace(/p$/i, "") } + : {}), + }; try { if (mode === "text") { if (logicalModelIDForConfig(requestConfig)) { diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index 8faa96862..6bf208a27 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; -import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } from "../src/lib/model-selection"; +import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel, resolveModelGenerationDefaults } from "../src/lib/model-selection"; import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; @@ -53,6 +53,82 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide } describe("逻辑模型选择", () => { + test("图片逻辑模型忽略全局视频时长,不应显示为不支持当前时长", () => { + const model = "platform::gpt-image-2"; + const channel: ModelChannel = { + id: "platform", + name: "平台模型", + baseUrl: "/api", + apiKey: "system", + apiFormat: "openai", + scope: "system", + enabled: true, + models: ["gpt-image-2"], + modelCosts: [{ + model: "gpt-image-2", + displayName: "GPT Image 2", + capability: "image", + billingMode: "fixed_request", + unitPriceMicrocredits: 1, + logicalCapabilitySpec: { + version: 1, + capability: "image", + options: { size: { values: ["1:1", "3:2"] } }, + }, + }], + }; + const config: AiConfig = { + ...defaultConfig, + channels: [channel], + models: [model], + imageModels: [model], + imageModel: model, + model, + }; + + expect(modelCompatibilityError(config, model, { + capability: "image", + input: { textCount: 1, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 }, + videoSeconds: "6", + imageSize: "3:2", + options: { size: "3:2" }, + })).toBe(""); + }); + + test("后台图片默认比例优先于旧的全局 1:1 配置", () => { + const model = "platform::managed-image"; + const profile = defaultModelCapabilityConfig(undefined, "managed-image"); + profile.image!.size = { parameter: "size", values: ["1:1", "16:9"], default: "16:9", allowCustom: false }; + const config: AiConfig = { + ...defaultConfig, + size: "1:1", + channels: [{ id: "platform", name: "平台模型", baseUrl: "/api", apiKey: "system", apiFormat: "openai", scope: "system", models: ["managed-image"], modelCosts: [{ model: "managed-image", capability: "image", billingMode: "fixed_request", unitPriceMicrocredits: 1, logicalModelId: "managed-image", logicalCapabilitySpec: { version: 1, capability: "image" }, capabilityConfig: profile }] }], + models: [model], + imageModels: [model], + imageModel: model, + model, + }; + + expect(resolveModelGenerationDefaults(config, model, "image", {}, { size: "1:1" }).size).toBe("16:9"); + }); + + test("后台视频默认时长优先于旧的全局 6 秒配置", () => { + const model = "platform::managed-video"; + const profile = defaultModelCapabilityConfig(undefined, "managed-video"); + profile.video!.duration = { selection: "enum", values: [6, 15], default: 15 }; + const config: AiConfig = { + ...defaultConfig, + videoSeconds: "6", + channels: [{ id: "platform", name: "平台模型", baseUrl: "/api", apiKey: "system", apiFormat: "openai", scope: "system", models: ["managed-video"], modelCosts: [{ model: "managed-video", capability: "video", billingMode: "per_second", unitPriceMicrocredits: 1, logicalModelId: "managed-video", logicalCapabilitySpec: { version: 1, capability: "video" }, capabilityConfig: profile }] }], + models: [model], + videoModels: [model], + videoModel: model, + model, + }; + + expect(resolveModelGenerationDefaults(config, model, "video", {}, { videoSeconds: "6" }).videoSeconds).toBe("15"); + }); + test("后台标注的视频模型不因内部标识缺少视频关键词而回退", () => { const config = policyConfig(); const selectedModel = config.videoModels[0]!;