Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-connection-policy.ts

88 lines
5.4 KiB
TypeScript

import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
import { getNodeAcceptedInputKinds, getNodeGenerationMode, getNodeInputKind, getNodeMaxInputCount } from "@/lib/canvas/node-registry";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
type CanvasConnectionPolicyOptions = {
// 仅跳过参考素材数量上限,媒体类型不兼容仍然拒绝。
ignoreCapacity?: boolean;
};
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) {
const target = nodes.find((node) => node.id === candidate.toNodeId);
if (!target) return "找不到连线目标节点";
const acceptedInputKinds = getNodeAcceptedInputKinds(target.type);
if (acceptedInputKinds.length) {
const source = nodes.find((node) => node.id === candidate.fromNodeId);
const sourceKind = source ? getNodeInputKind(source.type) : undefined;
const isMediaConversion = target.type === CanvasNodeType.MediaConversion;
const hasAcceptedSource = isMediaConversion
? source?.type === CanvasNodeType.Image || source?.type === CanvasNodeType.Video
: Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
if (!sourceKind || !hasAcceptedSource) {
const labels = acceptedInputKinds.map(acceptedInputKindLabel).join("或");
const targetLabel = isMediaConversion ? "转换" : target.type === CanvasNodeType.BatchTable ? "批量创作表" : labels;
return `${targetLabel}节点只接受${labels}输入`;
}
const maxInputCount = getNodeMaxInputCount(target.type);
if (maxInputCount) {
const inputCount = new Set(
[...connections, { id: "candidate", ...candidate }]
.filter((connection) => connection.toNodeId === target.id)
.map((connection) => connection.fromNodeId),
).size;
if (inputCount > maxInputCount) return `${isMediaConversion ? "转换" : "当前"}节点最多连接 ${maxInputCount} 个输入`;
}
}
const mode = getNodeGenerationMode(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 options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图");
}
if (mode === "video") {
return options.ignoreCapacity ? "" : 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 "";
}
function acceptedInputKindLabel(kind: "image" | "video" | "audio" | "text" | "table_data") {
if (kind === "image") return "图片";
if (kind === "video") return "视频";
if (kind === "audio") return "音频";
if (kind === "table_data") 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) return;
// 生成配置与背板不是参考素材,不参与容量计数——这一步必须早于角色卡判定,
// 否则一个带角色元数据的配置/背板节点会被多算成角色。
const inputKind = getNodeInputKind(source.type);
if (!inputKind) return;
// 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
else if (inputKind !== "table_data") input[`${inputKind}Count`] += 1;
});
return input;
}
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}`;
}