diff --git a/web/package.json b/web/package.json index 3e8ce83c9..61733c36b 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 9788a2cdc..02b0ba782 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -7,7 +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, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities"; +import { modelCompatibilityError, resolveCompatibleModel, 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"; @@ -19,7 +20,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasVi type CanvasConfigNodePanelProps = { node: CanvasNodeData; isRunning: boolean; - inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number }; + inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number; characterCount: number }; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string) => void; onStop: (nodeId: string) => void; @@ -45,8 +46,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const mode = node.metadata?.generationMode || "image"; const simpleMode = workspaceMode === "simple"; - const config = buildNodeConfig(globalConfig, node, mode); - const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, config.model).image! : undefined; + const requirements: ModelRequirements = { + capability: mode, + input: inputSummary, + videoOperation: node.metadata?.videoEditOperation, + videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, + }; + const config = buildNodeConfig(globalConfig, node, mode, requirements); const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions; const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); @@ -54,13 +60,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 }); const hasPrice = creditsEnabled && credits !== null; const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; - const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount); + const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount); const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); - const capabilityError = imageProfile - ? imageCapabilityError(imageProfile, inputSummary) - : videoProfile - ? videoCapabilityError(videoProfile, config.videoSeconds, inputSummary, node.metadata?.videoEditOperation) - : ""; + const capabilityError = modelCompatibilityError(config, config.model, requirements); const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return ( @@ -120,6 +122,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC + {inputSummary.characterCount ? : null}
- } title="文本生成" onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="分镜脚本" onClick={() => onCreate(CanvasNodeType.Script)} /> - } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> - {canCreateDrawing ? } title="绘图" onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} - } title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> - } title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} /> + } title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> + } title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> + {canCreateDrawing ? } title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} + } title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> + } title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
); } -function ConnectionCreateOption({ motionEnabled, icon, title, description, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; onClick: () => void }) { +function ConnectionCreateOption({ motionEnabled, icon, title, description, disabledReason, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; disabledReason?: string; onClick: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( - + {icon} {title} - {description ? {description} : null} + {disabledReason || description ? {disabledReason || description} : null} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 98eebfe78..0ed3119b5 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -4,6 +4,7 @@ import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; +import { compatibleModelInGroup, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -20,9 +21,10 @@ type ModelPickerProps = { onMissingConfig?: () => void; showSelectedPrice?: boolean; variant?: "default" | "creation"; + requirements?: ModelRequirements; }; -export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) { +export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements }: ModelPickerProps) { const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const pickerId = useId(); // 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃 @@ -43,15 +45,16 @@ export function ModelPicker({ config, value, onChange, capability, className, fu key: channel.id, label: channel.name || "未命名渠道", scope: channel.scope === "system" ? "系统渠道" : "自定义渠道", - models: options.filter((model) => resolveModelChannel(config, model).id === channel.id), + models: groupModelsByDisplayName(config, options.filter((model) => resolveModelChannel(config, model).id === channel.id)), })) .filter((group) => group.models.length); - const groupedModels = new Set(channelGroups.flatMap((group) => group.models)); + const groupedModels = new Set(channelGroups.flatMap((group) => group.models.flatMap((modelGroup) => modelGroup.models))); const ungroupedModels = options.filter((model) => !groupedModels.has(model)); - return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: ungroupedModels }] : channelGroups; + return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups; }, [config, options]); const current = value || ""; - const currentPrice = modelMenuPrice(config, current); + const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current; + const currentPrice = modelMenuPrice(config, resolvedCurrent); const creationVariant = variant === "creation"; useEffect(() => { @@ -136,23 +139,30 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
- {group.models.map((model) => { - const selected = model === current; + {group.models.map((modelGroup) => { + const selected = modelGroup.models.includes(current); + const model = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined); + const displayModel = model || (selected ? current : modelGroup.models[0]); + const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力"; return ( ); @@ -220,6 +230,7 @@ function ModelLabel({ theme, creationVariant, showPrice, + disabledReason, }: { config: AiConfig; model: string; @@ -227,10 +238,11 @@ function ModelLabel({ theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showPrice: boolean; + disabledReason?: string; }) { const meta = modelMenuMeta(model, capability); const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined; - const capabilitySummary = videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description; + const capabilitySummary = disabledReason || (videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description); return ( diff --git a/web/src/lib/canvas/canvas-connection-policy.ts b/web/src/lib/canvas/canvas-connection-policy.ts new file mode 100644 index 000000000..eb8d02fc7 --- /dev/null +++ b/web/src/lib/canvas/canvas-connection-policy.ts @@ -0,0 +1,58 @@ +import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection"; +import type { AiConfig } from "@/stores/use-config-store"; +import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas"; + +type ConnectionCandidate = Pick; + +export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate) { + const target = nodes.find((node) => node.id === candidate.toNodeId); + if (!target) return "找不到连线目标节点"; + const mode = nodeGenerationMode(target); + if (!mode) return ""; + const input = connectionInputSummary(target.id, nodes, connections, candidate); + const visualInputCount = input.imageCount + input.characterCount; + + if (mode === "image") { + if (input.videoCount > 0) return "图片生成节点不能连接参考视频"; + if (input.audioCount > 0) return "图片生成节点不能连接参考音频"; + return capacityError(config, mode, "image", visualInputCount, "参考图"); + } + if (mode === "video") { + return capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频"); + } + if (mode === "text" && input.audioCount > 0) return "文本生成节点不能连接参考音频"; + if (mode === "audio" && input.characterCount > 1) return "角色配音一次只能连接一个角色卡"; + if (mode === "audio" && (input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0)) return "音频生成节点只接受文本或单个角色卡输入"; + return ""; +} + +export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate?: ConnectionCandidate): ModelInputSummary { + const sourceIds = new Set([...connections, ...(candidate ? [{ id: "candidate", ...candidate }] : [])].filter((connection) => connection.toNodeId === targetNodeId).map((connection) => connection.fromNodeId)); + const input: ModelInputSummary = { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 }; + sourceIds.forEach((sourceId) => { + const source = nodes.find((node) => node.id === sourceId); + if (!source || source.type === CanvasNodeType.Config || source.type === CanvasNodeType.Frame) return; + if (source.metadata?.workflowKind === "character") input.characterCount += 1; + else if (source.type === CanvasNodeType.Image || source.type === CanvasNodeType.Drawing) input.imageCount += 1; + else if (source.type === CanvasNodeType.Video) input.videoCount += 1; + else if (source.type === CanvasNodeType.Audio) input.audioCount += 1; + else input.textCount += 1; + }); + return input; +} + +function nodeGenerationMode(node: CanvasNodeData): CanvasGenerationMode | null { + if (node.type === CanvasNodeType.Config) return node.metadata?.generationMode || "image"; + if (node.type === CanvasNodeType.Image) return "image"; + if (node.type === CanvasNodeType.Video) return "video"; + if (node.type === CanvasNodeType.Audio) return "audio"; + if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script) return "text"; + return null; +} + +function capacityError(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio", count: number, label: string) { + const maximum = maxModelInputCapacity(config, capability, kind); + if (maximum === null || count <= maximum) return ""; + const unit = kind === "image" ? "张" : "个"; + return maximum > 0 ? `已配置模型最多支持 ${maximum} ${unit}${label}` : `已配置模型均不支持${label}`; +} diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index e4acd96d5..1a322755a 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -238,6 +238,7 @@ export function getInputSummary(inputs: NodeGenerationInput[]) { imageCount: inputs.filter((input) => input.type === "image").length, videoCount: inputs.filter((input) => input.type === "video").length, audioCount: inputs.filter((input) => input.type === "audio").length, + characterCount: inputs.filter((input) => input.type === "character").length, }; } diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index c80de7c38..074a81fd9 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -8,6 +8,7 @@ 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 { resolveCompatibleModel, 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"; @@ -202,13 +203,14 @@ function resolveVideoEditOperation( }, ): CanvasVideoEditOperation { const storedOperation = node?.metadata?.videoEditOperation; - // 连接关系是生成时的真实输入,不能让分镜节点残留的文生视频模式丢弃后来连接的参考图。 - if (storedOperation === "text_to_video" && context?.referenceImages.length) return "image_to_video"; - if (storedOperation) return storedOperation; - if (context?.referenceAudios.length && !context.referenceImages.length && !context.referenceVideos.length) return "audio_to_video"; - if (context?.referenceVideos.length) return "extend"; - if (context?.referenceImages.length) return "image_to_video"; - return "image_to_video"; + const input = { + textCount: 0, + imageCount: context?.referenceImages.length || 0, + videoCount: context?.referenceVideos.length || 0, + audioCount: context?.referenceAudios.length || 0, + characterCount: 0, + }; + return resolveVideoOperation(input, storedOperation) as CanvasVideoEditOperation; } export function buildVideoGenerationMetadata( @@ -284,11 +286,12 @@ export function getGenerationCount(count: string) { } -export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig { +export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig { const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode); - const model = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel; + const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel; + const model = resolveCompatibleModel(config, preferredModel, requirements) || preferredModel; const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined; const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground, count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count) }) : undefined; return { diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts new file mode 100644 index 000000000..50ceac81e --- /dev/null +++ b/web/src/lib/model-selection.ts @@ -0,0 +1,149 @@ +import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities"; +import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; + +export type ModelInputSummary = { + textCount: number; + imageCount: number; + videoCount: number; + audioCount: number; + characterCount: number; +}; + +export type ModelRequirements = { + capability?: ModelCapability; + input?: ModelInputSummary; + videoOperation?: string; + videoSeconds?: string; +}; + +export type DisplayModelGroup = { + key: string; + label: string; + models: string[]; +}; + +export type ModelReferenceLimits = { + maxImages: number; + maxVideos: number; + maxAudios: number; +}; + +export function groupModelsByDisplayName(config: AiConfig, models: string[]): DisplayModelGroup[] { + const groups = new Map(); + models.forEach((model) => { + const channel = resolveModelChannel(config, model); + const label = configuredModelDisplayName(config, model); + const key = `${channel.id}\u0000${label.toLocaleLowerCase()}`; + const current = groups.get(key); + if (current) current.models.push(model); + else groups.set(key, { key, label, models: [model] }); + }); + return Array.from(groups.values()); +} + +export function configuredModelDisplayName(config: AiConfig, value: string) { + const model = modelOptionName(value); + const channel = resolveModelChannel(config, value); + return channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim() || model; +} + +export function modelCompatibilityError(config: AiConfig, model: string, requirements?: ModelRequirements) { + const capability = requirements?.capability; + const input = requirements?.input; + if (!capability || !input) return ""; + const visualInputCount = input.imageCount + input.characterCount; + + if (capability === "image") { + if (input.videoCount > 0) return "图片模型不支持参考视频"; + if (input.audioCount > 0) return "图片模型不支持参考音频"; + const maxImages = modelCapabilityConfigFor(config, model).image!.references.maxImages; + if (visualInputCount > maxImages) return `最多支持 ${maxImages} 张参考图`; + return ""; + } + + if (capability === "video") { + const profile = modelCapabilityConfigFor(config, model).video!; + if (visualInputCount > profile.references.maxImages) return `最多支持 ${profile.references.maxImages} 张参考图`; + if (input.videoCount > profile.references.maxVideos) return `最多支持 ${profile.references.maxVideos} 个参考视频`; + if (input.audioCount > profile.references.maxAudios) return `最多支持 ${profile.references.maxAudios} 个参考音频`; + if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长"; + const operation = resolveVideoOperation(input, requirements.videoOperation); + if (operation !== "concat" && !profile.operations.includes(operation)) return `不支持${videoOperationLabel(operation)}`; + return ""; + } + + if (capability === "text") { + return input.audioCount > 0 ? "文本模型不支持参考音频" : ""; + } + + if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡"; + return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : ""; +} + +export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) { + const ordered = preferred && models.includes(preferred) ? [preferred, ...models.filter((model) => model !== preferred)] : models; + return ordered.find((model) => !modelCompatibilityError(config, model, requirements)) || ""; +} + +export function resolveCompatibleModel(config: AiConfig, selected: string, requirements?: ModelRequirements) { + if (!requirements?.capability) return selected; + const options = selectableModelsByCapability(config, requirements.capability); + if (!options.length) return selected; + const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected)); + if (!selectedGroup) return selected; + return compatibleModelInGroup(config, selectedGroup.models, requirements, selected); +} + +export function maxModelInputCapacity(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio") { + const options = selectableModelsByCapability(config, capability); + if (!options.length) return null; + return options.reduce((maximum, model) => { + const profile = modelCapabilityConfigFor(config, model); + const value = + capability === "image" ? (kind === "image" ? profile.image!.references.maxImages : 0) : kind === "image" ? profile.video!.references.maxImages : kind === "video" ? profile.video!.references.maxVideos : profile.video!.references.maxAudios; + return Math.max(maximum, value); + }, 0); +} + +export function modelGroupReferenceLimits(config: AiConfig, selected: string, capability: ModelCapability, requirements?: ModelRequirements): ModelReferenceLimits | undefined { + if (capability !== "image" && capability !== "video") return undefined; + const options = selectableModelsByCapability(config, capability); + const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected)); + const groupModels = selectedGroup?.models || (selected ? [selected] : []); + if (!groupModels.length) return undefined; + const compatibleModels = requirements ? groupModels.filter((model) => !modelCompatibilityError(config, model, requirements)) : groupModels; + const models = compatibleModels.length ? compatibleModels : groupModels; + return models.reduce((limits, model) => { + const profile = modelCapabilityConfigFor(config, model); + if (capability === "image") { + return { ...limits, maxImages: Math.max(limits.maxImages, profile.image!.references.maxImages) }; + } + const references = profile.video!.references; + return { + maxImages: Math.max(limits.maxImages, references.maxImages), + maxVideos: Math.max(limits.maxVideos, references.maxVideos), + maxAudios: Math.max(limits.maxAudios, references.maxAudios), + }; + }, { maxImages: 0, maxVideos: 0, maxAudios: 0 }); +} + +export function inferVideoOperation(input: ModelInputSummary) { + const visualInputCount = input.imageCount + input.characterCount; + if (input.audioCount > 0 && visualInputCount === 0 && input.videoCount === 0) return "audio_to_video"; + if (input.videoCount > 0) return "extend"; + if (visualInputCount > 0) return "image_to_video"; + return "text_to_video"; +} + +export function resolveVideoOperation(input: ModelInputSummary, storedOperation?: string) { + if (storedOperation && !["text_to_video", "image_to_video", "audio_to_video", "extend"].includes(storedOperation)) return storedOperation; + return inferVideoOperation(input); +} + +function videoOperationLabel(operation: string) { + if (operation === "text_to_video") return "文生视频"; + if (operation === "image_to_video") return "图生视频"; + if (operation === "audio_to_video") return "音频生视频"; + if (operation === "extend") return "视频续写"; + return "当前生成模式"; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 1b72c52d7..f32cc16df 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -705,9 +705,10 @@ function InfiniteCanvasPage() { onNodesDeleted: handleNodesDeleted, }); - const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, handleConnectStart, mouseWorld, pendingConnectionCreate, setConnecting } = + const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, getConnectionCreateDisabledReason, handleConnectStart, mouseWorld, pendingConnectionCreate, setConnecting } = useCanvasConnectionController({ projectId, + config: effectiveConfig, defaultDrawingEngine, nodesRef, connectionsRef, @@ -1786,6 +1787,7 @@ function InfiniteCanvasPage() { viewportSize={size} containerRef={containerRef} canCreateDrawing={canCreateDrawingFromConnection} + getDisabledReason={(type) => getConnectionCreateDisabledReason(type, pendingConnectionCreate)} onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index bc2cc2fc1..d7d997547 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -4,15 +4,18 @@ import { nanoid } from "nanoid"; import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; import { getNodeSpec } from "@/constant/canvas"; +import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy"; import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain"; import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage"; import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine"; import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame"; +import type { AiConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas"; type UseCanvasConnectionControllerOptions = { projectId: string; + config: AiConfig; defaultDrawingEngine: CanvasDrawingEngine; nodesRef: { current: CanvasNodeData[] }; connectionsRef: { current: CanvasConnection[] }; @@ -41,6 +44,7 @@ const NODE_STATUS_IDLE = "idle" as const; export function useCanvasConnectionController({ projectId, + config, defaultDrawingEngine, nodesRef, connectionsRef, @@ -105,6 +109,11 @@ export function useCanvasConnectionController({ const toHandleId = toNodeId === current.nodeId ? current.handleId : targetHandleId; const fromAnchorRatio = fromNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio; const toAnchorRatio = toNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio; + const policyError = canvasConnectionError(config, nodesRef.current, connectionsRef.current, { fromNodeId, toNodeId }); + if (policyError) { + message.warning(policyError); + return; + } const exists = connectionsRef.current.find((item) => item.fromNodeId === fromNodeId && item.toNodeId === toNodeId && item.fromHandleId === fromHandleId && item.toHandleId === toHandleId); if (exists) { setConnections((currentConnections) => currentConnections.map((item) => item.id === exists.id ? { ...item, fromAnchorRatio, toAnchorRatio } : item)); @@ -113,7 +122,7 @@ export function useCanvasConnectionController({ setNodes((currentNodes) => attachNodeToStoryboardRow(currentNodes, { fromNodeId, toNodeId, fromHandleId, toHandleId })); } setContextMenu(null); - }, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]); + }, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]); const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => { if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) { @@ -149,6 +158,11 @@ export function useCanvasConnectionController({ message.warning("配置节点之间不能连接"); return; } + const policyError = canvasConnectionError(config, [...nodesRef.current, newNode], connectionsRef.current, connection); + if (policyError) { + message.warning(policyError); + return; + } if (type === CanvasNodeType.Drawing) { const drawingSourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId); const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : ""; @@ -192,7 +206,16 @@ export function useCanvasConnectionController({ else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id); closeConnectionCreateMenu(); setConnecting(null); - }, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]); + }, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]); + + const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => { + const spec = getNodeSpec(type); + const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: spec.width, height: spec.height }; + const pendingNodes = [...nodesRef.current, pendingNode]; + const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType); + if (!connection) return "当前节点类型不能这样连接"; + return canvasConnectionError(config, pendingNodes, connectionsRef.current, connection); + }, [config, connectionsRef, nodesRef]); const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => { const world = screenToCanvas(clientX, clientY); @@ -221,7 +244,8 @@ export function useCanvasConnectionController({ const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding; if (!hitsHandle && !hitsInside && !hitsExpanded) return; isNearNode = true; - if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return; + const normalized = node.id === current.nodeId ? null : normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType); + if (!normalized || canvasConnectionError(config, nodesRef.current, connectionsRef.current, normalized)) return; const priority = hitsInside ? 0 : hitsHandle ? 1 : 2; if (priority < bestPriority) { bestNodeId = node.id; @@ -231,7 +255,7 @@ export function useCanvasConnectionController({ } }); return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode }; - }, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]); + }, [config, connectionsRef, nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]); const finishConnection = useCallback((clientX: number, clientY: number) => { if (pendingConnectionCreateRef.current) return; @@ -313,6 +337,7 @@ export function useCanvasConnectionController({ connectionTargetAnchorRatio, connectingParams, createConnectedNode, + getConnectionCreateDisabledReason, handleConnectStart, mouseWorld, pendingConnectionCreate, diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index ec37930bb..c2ad92d19 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -3,12 +3,13 @@ import { App } from "antd"; import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; -import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation"; +import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation"; import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch"; import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error"; +import { modelCompatibilityError, modelGroupReferenceLimits, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; @@ -127,14 +128,25 @@ export function useCanvasGenerationExecutor({ // 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。 const promptOnly = mode === "video"; try { + const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, promptOnly); + const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig.videoSeconds, true); + generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements); + const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements); + if (compatibilityError) throw new Error(`当前逻辑模型没有可用的细分模型:${compatibilityError}`); + const referenceLimits = modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements); rawGenerationContext = await hydrateNodeGenerationContext( - buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, promptOnly), + baseContext, projectId, domainProjectId, mode, - mode === "video" && supportsVideoReferenceAudio(generationConfig), + mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, + referenceLimits, ); + const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig.videoSeconds); + generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements); + const hydratedCompatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements); + if (hydratedCompatibilityError) throw new Error(`当前逻辑模型没有可用的细分模型:${hydratedCompatibilityError}`); } catch (error) { const errorDetails = generationErrorMessage(error); if (isPreparingEmptyImage) { @@ -258,3 +270,18 @@ export function useCanvasGenerationExecutor({ [addedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest], ); } + +function generationModelRequirements(mode: CanvasNodeGenerationMode, input: Pick>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">, sourceNode: CanvasNodeData | undefined, videoSeconds: string, includeCharacterMinimum = false): ModelRequirements { + return { + capability: mode, + input: { + textCount: input.textCount, + imageCount: input.imageCount, + videoCount: input.videoCount, + audioCount: input.audioCount, + characterCount: includeCharacterMinimum ? input.characterReferences.length : 0, + }, + videoOperation: sourceNode?.metadata?.videoEditOperation, + videoSeconds, + }; +} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 3260accd7..d05d834d7 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -14,6 +14,7 @@ import { createClientId } from "@/lib/client-id"; import { generationErrorMessage } from "@/lib/generation-error"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; +import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { requestImageQuestion } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; @@ -156,7 +157,20 @@ export default function CreatePage() { () => conversations.filter((conversation) => conversation.id === activeId || conversation.messages.length > 0).sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)), [activeId, conversations], ); - const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel; + const preferredModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel; + const hasPrompt = Boolean(prompt.trim()); + const modelRequirements = useMemo(() => ({ + capability: mode, + input: { + textCount: hasPrompt ? 1 : 0, + imageCount: attachments.filter(isImageAttachment).length, + videoCount: attachments.filter(isVideoAttachment).length, + audioCount: 0, + characterCount: 0, + }, + videoSeconds: seconds, + }), [attachments, hasPrompt, mode, seconds]); + const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); @@ -568,6 +582,7 @@ export default function CreatePage() { onFileChange: handleFileChange, onModeChange: selectMode, model: selectedModel, + modelRequirements, imageProfile, videoProfile, config, @@ -784,6 +799,7 @@ type ComposerProps = { onFileChange: (event: ChangeEvent) => void; onModeChange: (mode: CreationMode) => void; model: string; + modelRequirements: ModelRequirements; videoProfile: VideoCapabilityConfig; imageProfile: ImageCapabilityConfig; config: ReturnType; @@ -838,7 +854,7 @@ function CreationComposer(props: ComposerProps) { /> - + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts new file mode 100644 index 000000000..b2d9f78e1 --- /dev/null +++ b/web/test/canvas-model-policy.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; + +import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } 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"; + +function policyConfig(): AiConfig { + const variants = [ + { model: "cinema-text", operations: ["text_to_video"], maxImages: 0, maxVideos: 0, maxAudios: 0 }, + { model: "cinema-image", operations: ["image_to_video"], maxImages: 1, maxVideos: 0, maxAudios: 0 }, + { model: "cinema-audio", operations: ["audio_to_video"], maxImages: 0, maxVideos: 0, maxAudios: 1 }, + ]; + const channel: ModelChannel = { + id: "relay", + name: "中转渠道", + baseUrl: "https://api.example.com", + apiKey: "test-key", + apiFormat: "openai", + models: variants.map((item) => item.model), + modelCosts: variants.map((item) => { + const capabilityConfig = defaultModelCapabilityConfig(undefined, item.model); + capabilityConfig.video!.operations = item.operations; + capabilityConfig.video!.references.maxImages = item.maxImages; + capabilityConfig.video!.references.maxVideos = item.maxVideos; + capabilityConfig.video!.references.maxAudios = item.maxAudios; + return { + model: item.model, + displayName: "Cinema Pro", + capability: "video" as const, + billingMode: "per_second" as const, + unitPriceMicrocredits: 1, + capabilityConfig, + }; + }), + }; + const models = variants.map((item) => `relay::${item.model}`); + return { ...defaultConfig, channels: [channel], models, videoModels: models, model: models[0], videoModel: models[0] }; +} + +function node(id: string, type: CanvasNodeType, generationMode?: "image" | "video"): CanvasNodeData { + return { + id, + type, + title: id, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: generationMode ? { generationMode } : undefined, + }; +} + +describe("逻辑模型选择", () => { + test("同渠道同显示名称合并为一个逻辑模型", () => { + const config = policyConfig(); + const groups = groupModelsByDisplayName(config, config.videoModels); + expect(groups).toHaveLength(1); + expect(groups[0]?.models).toHaveLength(3); + }); + + test("根据参考图自动切换到图生视频细分模型", () => { + const config = policyConfig(); + const resolved = resolveCompatibleModel(config, "relay::cinema-text", { + capability: "video", + input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 }, + videoOperation: "text_to_video", + videoSeconds: "6", + }); + expect(resolved).toBe("relay::cinema-image"); + }); + + test("音频输入只匹配支持音频的细分模型", () => { + const config = policyConfig(); + const requirements = { + capability: "video" as const, + input: { textCount: 1, imageCount: 0, videoCount: 0, audioCount: 1, characterCount: 0 }, + videoSeconds: "6", + }; + expect(resolveCompatibleModel(config, "relay::cinema-text", requirements)).toBe("relay::cinema-audio"); + expect(modelCompatibilityError(config, "relay::cinema-image", requirements)).toContain("参考音频"); + }); + + test("逻辑模型容量使用同名细分模型的最大值", () => { + const config = policyConfig(); + expect(modelGroupReferenceLimits(config, "relay::cinema-text", "video")).toEqual({ maxImages: 1, maxVideos: 0, maxAudios: 1 }); + }); +}); + +describe("画布连线能力", () => { + test("超过所有视频模型的参考图上限时拒绝连线", () => { + const config = policyConfig(); + const nodes = [node("image-a", CanvasNodeType.Image), node("image-b", CanvasNodeType.Image), node("target", CanvasNodeType.Config, "video")]; + const connections: CanvasConnection[] = [{ id: "existing", fromNodeId: "image-a", toNodeId: "target" }]; + expect(canvasConnectionError(config, nodes, connections, { fromNodeId: "image-b", toNodeId: "target" })).toContain("最多支持 1"); + }); + + test("存在音频细分模型时允许音频连接视频生成节点", () => { + const config = policyConfig(); + const nodes = [node("audio", CanvasNodeType.Audio), node("target", CanvasNodeType.Config, "video")]; + expect(canvasConnectionError(config, nodes, [], { fromNodeId: "audio", toNodeId: "target" })).toBe(""); + }); + + test("视频结果不能连接到图片生成节点", () => { + const config = policyConfig(); + const nodes = [node("video", CanvasNodeType.Video), node("target", CanvasNodeType.Image)]; + expect(canvasConnectionError(config, nodes, [], { fromNodeId: "video", toNodeId: "target" })).toContain("不能连接参考视频"); + }); + + test("单个角色卡可以连接到音频生成节点", () => { + const config = policyConfig(); + const character = { ...node("character", CanvasNodeType.Image), metadata: { workflowKind: "character" as const, characterAssetId: "character-asset" } }; + const nodes = [character, node("target", CanvasNodeType.Audio)]; + expect(canvasConnectionError(config, nodes, [], { fromNodeId: "character", toNodeId: "target" })).toBe(""); + }); +});