feat(canvas): 画布模型与连线能力 - 合并同名模型并按输入动态限制节点关联

Closes #220
This commit is contained in:
ddCat
2026-08-14 12:41:55 +08:00
parent cfdf08d05f
commit e7af466a16
16 changed files with 509 additions and 80 deletions

View File

@@ -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<CanvasConnection, "fromNodeId" | "toNodeId">;
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}`;
}

View File

@@ -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,
};
}

View File

@@ -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 {