refactor(canvas): 节点注册表 - 收敛素材类型、生成模式与输入计数判据

把三条按类型分支的语义判据收进注册表:
- resourceKind  原 canvas-resource-references.ts,作为 @ 引用素材时的类型
- generationMode 原 canvas-connection-policy.ts,作为生成节点时的模式
- inputKind     原 canvas-connection-policy.ts,被容量校验计数时的类别

resourceKind 与 inputKind 刻意分成两个字段:它们语义不同——空图片节点不是素材
(不进 @ 引用),但照样计入 imageCount。合成一个会静默改掉连线容量校验。

两处必须保住的语义,均已由测试钉住:
1. 角色卡(workflowKind === character)是跨类型覆盖,对任何节点类型生效,
   属于查注册表之前的前置判定,因此留在调用点而不进 per-type 定义。
2. 排除必须早于覆盖。原实现先 return 掉生成配置与背板,之后才判角色卡;
   若把角色卡判定提前,带角色元数据的配置/背板节点会被多算成 characterCount。

新增 test/canvas-node-registry.test.ts(19 用例 / 77 断言)并加入 package.json
的 test 列表——该列表是显式枚举,不加进去文件不会跑。补测的原因是
canvas-resource-references.test.ts 只覆盖 collectUpstreamVideoNodes,
本次改动的 resourceKind 此前零覆盖,而这类判据出错不报错、只会让 @ 引用少收素材。

顺带内联 nodeGenerationMode(迁移后退化为单行转发)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlh
2026-08-20 22:27:23 +08:00
parent af84cf5772
commit a4585fdc2a
8 changed files with 245 additions and 27 deletions

View File

@@ -1,6 +1,7 @@
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
import { getNodeGenerationMode, getNodeInputKind } from "@/lib/canvas/node-registry";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import { type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
type CanvasConnectionPolicyOptions = {
@@ -11,7 +12,7 @@ type CanvasConnectionPolicyOptions = {
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 mode = nodeGenerationMode(target);
const mode = getNodeGenerationMode(target);
if (!mode) return "";
const input = connectionInputSummary(target.id, nodes, connections, candidate);
const visualInputCount = input.imageCount + input.characterCount;
@@ -35,25 +36,18 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa
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) return;
// 生成配置与背板不是参考素材,不参与容量计数——这一步必须早于角色卡判定,
// 否则一个带角色元数据的配置/背板节点会被多算成角色。
const inputKind = getNodeInputKind(source.type);
if (!inputKind) 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;
else input[`${inputKind}Count`] += 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 "";

View File

@@ -1,4 +1,5 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { Skill } from "@/services/api/skills";
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
@@ -146,14 +147,9 @@ function isResourceNode(node: CanvasNodeData) {
}
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
// 角色卡是跨类型覆盖:任何节点带上角色元数据都按角色处理,故先于按类型判定。
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) return "character";
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) return "image";
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
if (node.type === CanvasNodeType.Skill && (node.metadata?.skillSnapshot || node.metadata?.content)) return "text";
return null;
return getNodeResourceKind(node);
}
function skillResourceText(node: CanvasNodeData) {

View File

@@ -26,18 +26,27 @@ const BUILTIN_NODE_TRAITS = {
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "image" : null),
generationMode: () => "image",
inputKind: "image",
},
[CanvasNodeType.Text]: {
label: "文本",
icon: <Type />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.prompt ? "text" : null),
generationMode: () => "text",
inputKind: "text",
},
[CanvasNodeType.Drawing]: {
label: "绘图",
icon: <Pencil />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
// 绘图产出的是图像,所以作为素材与输入都按图片计。
resourceKind: (node: CanvasNodeData) => (node.metadata?.drawingId ? "image" : null),
inputKind: "image",
},
[CanvasNodeType.Script]: {
label: "分镜脚本",
@@ -45,6 +54,8 @@ const BUILTIN_NODE_TRAITS = {
// 分镜脚本的表格布局需要更宽的下限;高度仍由内容动态撑开(见 canvas-node.tsx)。
minSize: { width: 800, height: DEFAULT_MIN_SIZE.height },
showInCreateMenu: true,
generationMode: () => "text",
inputKind: "text",
},
[CanvasNodeType.Skill]: {
label: "技能",
@@ -52,6 +63,9 @@ const BUILTIN_NODE_TRAITS = {
minSize: DEFAULT_MIN_SIZE,
// 技能节点由技能库插入,不占创建菜单格位。
showInCreateMenu: false,
// 技能注入的是提示词文本,故按文本素材计。
resourceKind: (node: CanvasNodeData) => (node.metadata?.skillSnapshot || node.metadata?.content ? "text" : null),
inputKind: "text",
},
[CanvasNodeType.Config]: {
label: "生成配置",
@@ -59,6 +73,9 @@ const BUILTIN_NODE_TRAITS = {
minSize: DEFAULT_MIN_SIZE,
// 生成配置由连线时自动创建。
showInCreateMenu: false,
// 生成模式由用户在配置节点上选择,缺省按图片。
generationMode: (node: CanvasNodeData) => node.metadata?.generationMode || "image",
// 不设 inputKind:配置节点本身不是参考素材,不参与容量计数。
},
[CanvasNodeType.Video]: {
label: "视频",
@@ -66,12 +83,18 @@ const BUILTIN_NODE_TRAITS = {
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: () => true,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "video" : null),
generationMode: () => "video",
inputKind: "video",
},
[CanvasNodeType.Audio]: {
label: "音频",
icon: <Music2 />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "audio" : null),
generationMode: () => "audio",
inputKind: "audio",
},
[CanvasNodeType.Frame]: {
label: "背板",
@@ -80,6 +103,7 @@ const BUILTIN_NODE_TRAITS = {
icon: <PanelTop />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
// 背板只是视觉容器,既不是素材也不参与计数。
},
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;

View File

@@ -1,3 +1,3 @@
export type { CanvasNodeDefinition } from "./node-definition";
export { getNodeDefinition, getNodeIcon, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition";
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
import "./definitions";

View File

@@ -1,6 +1,10 @@
import type { ReactNode } from "react";
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
/** 作为上游输入被容量校验计数时归入的类别 */
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
/**
* 节点定义——注册表的基本单元。
@@ -26,4 +30,19 @@ export type CanvasNodeDefinition = {
keepAspectRatio?: (node: CanvasNodeData) => boolean;
/** 是否出现在添加节点菜单(技能、生成配置由其他入口创建) */
showInCreateMenu: boolean;
/**
* 作为 @ 引用素材时归入的类型;不设或返回 null 表示该节点不是可引用素材。
* 判定依赖内容——空节点不构成素材。
*
* 注意:角色卡(workflowKind === "character")是**跨类型覆盖**,不在这里表达,
* 由 canvas-resource-references 在查注册表之前先行判定。
*/
resourceKind?: (node: CanvasNodeData) => CanvasResourceKind | null;
/** 作为生成节点时的生成模式;不设表示该类型不产生生成行为 */
generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null;
/**
* 作为上游输入被参考素材容量校验计数时的类别;
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
*/
inputKind?: CanvasNodeInputKind;
};

View File

@@ -72,3 +72,21 @@ export function getNodeMinSize(type: CanvasNodeType) {
export function shouldKeepAspectRatio(node: CanvasNodeData) {
return definitions.get(node.type)?.keepAspectRatio?.(node) ?? false;
}
/**
* 该节点作为 @ 引用素材时的类型,非素材返回 null。
* 只按类型判定——角色卡那类跨类型覆盖由调用方在此之前处理。
*/
export function getNodeResourceKind(node: CanvasNodeData) {
return definitions.get(node.type)?.resourceKind?.(node) ?? null;
}
/** 该节点作为生成节点时的生成模式,不产生生成行为返回 null */
export function getNodeGenerationMode(node: CanvasNodeData) {
return definitions.get(node.type)?.generationMode?.(node) ?? null;
}
/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */
export function getNodeInputKind(type: CanvasNodeType) {
return definitions.get(type)?.inputKind;
}