feat(canvas): 图片转换插件 - 增加本地媒体转换节点与运行时
This commit is contained in:
@@ -399,6 +399,7 @@ function canvasNodeTypeLabel(type?: CanvasNodeType) {
|
||||
if (type === CanvasNodeType.Frame) return "背板";
|
||||
if (type === CanvasNodeType.Drawing) return "绘图节点";
|
||||
if (type === CanvasNodeType.Skill) return "技能节点";
|
||||
if (type === CanvasNodeType.MediaConversion) return "转换节点";
|
||||
return "文本节点";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
|
||||
import { getNodeAcceptedInputKind, getNodeGenerationMode, getNodeInputKind } from "@/lib/canvas/node-registry";
|
||||
import { getNodeAcceptedInputKinds, getNodeGenerationMode, getNodeInputKind, getNodeMaxInputCount } from "@/lib/canvas/node-registry";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
|
||||
type CanvasConnectionPolicyOptions = {
|
||||
@@ -12,11 +12,27 @@ 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 acceptedInputKind = getNodeAcceptedInputKind(target.type);
|
||||
if (acceptedInputKind) {
|
||||
const acceptedInputKinds = getNodeAcceptedInputKinds(target.type);
|
||||
if (acceptedInputKinds.length) {
|
||||
const source = nodes.find((node) => node.id === candidate.fromNodeId);
|
||||
const sourceKind = source ? getNodeInputKind(source.type) : undefined;
|
||||
if (sourceKind !== acceptedInputKind) return `${acceptedInputKindLabel(acceptedInputKind)}节点只接受${acceptedInputKindLabel(acceptedInputKind)}输入`;
|
||||
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("或");
|
||||
return `${isMediaConversion ? "转换" : labels}节点只接受${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 "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Type, Video } from "lucide-react";
|
||||
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Type, Video, WandSparkles } from "lucide-react";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
@@ -25,7 +25,7 @@ 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),
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.storageKey ? "image" : null),
|
||||
generationMode: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
@@ -82,7 +82,7 @@ const BUILTIN_NODE_TRAITS = {
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
keepAspectRatio: () => true,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "video" : null),
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.storageKey ? "video" : null),
|
||||
generationMode: () => "video",
|
||||
inputKind: "video",
|
||||
},
|
||||
@@ -165,6 +165,20 @@ const BUILTIN_NODE_TRAITS = {
|
||||
resourceKind: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.MediaConversion]: {
|
||||
label: "转换",
|
||||
icon: <WandSparkles />,
|
||||
minSize: { width: 320, height: 360 },
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => {
|
||||
const conversion = node.metadata?.mediaConversion;
|
||||
if (conversion?.status !== "completed" || !conversion.resultStorageKey) return null;
|
||||
return conversion.outputKind === "video" ? "video" : "image";
|
||||
},
|
||||
acceptsInputKind: ["image", "video"],
|
||||
maxInputCount: 1,
|
||||
inputKind: "image",
|
||||
},
|
||||
} satisfies Record<string, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
|
||||
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition";
|
||||
export { getNodeAcceptedInputKind, getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, registerPluginCanvasNodes, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
|
||||
export { getNodeAcceptedInputKind, getNodeAcceptedInputKinds, getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMaxInputCount, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, registerPluginCanvasNodes, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
|
||||
import "./definitions";
|
||||
|
||||
@@ -43,8 +43,10 @@ export type CanvasNodeDefinition = {
|
||||
generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null;
|
||||
/** 是否显示右侧输出连接点;缺省为 true,消费型终点节点可关闭。 */
|
||||
showOutputConnection?: boolean;
|
||||
/** 若设置,作为目标时只接受这一类上游节点。 */
|
||||
acceptsInputKind?: CanvasNodeInputKind;
|
||||
/** 若设置,作为目标时只接受这些上游节点类型。 */
|
||||
acceptsInputKind?: CanvasNodeInputKind | CanvasNodeInputKind[];
|
||||
/** 限制直接输入数量;转换节点等一进一出节点通常为 1。 */
|
||||
maxInputCount?: number;
|
||||
/**
|
||||
* 作为上游输入被参考素材容量校验计数时的类别;
|
||||
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
|
||||
@@ -69,6 +71,7 @@ export function canvasNodeDefinitionFromPlugin(pluginId: string, contribution: P
|
||||
showInCreateMenu: true,
|
||||
showOutputConnection: contribution.showOutputConnection,
|
||||
acceptsInputKind: contribution.acceptsInputKind,
|
||||
maxInputCount: contribution.maxInputCount,
|
||||
plugin: { pluginId, renderer: contribution.renderer, schema: contribution.schema },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,3 +100,13 @@ export function getNodeInputKind(type: CanvasNodeTypeId) {
|
||||
export function getNodeAcceptedInputKind(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.acceptsInputKind;
|
||||
}
|
||||
|
||||
export function getNodeAcceptedInputKinds(type: CanvasNodeTypeId) {
|
||||
const accepted = definitions.get(type)?.acceptsInputKind;
|
||||
if (!accepted) return [];
|
||||
return Array.isArray(accepted) ? accepted : [accepted];
|
||||
}
|
||||
|
||||
export function getNodeMaxInputCount(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.maxInputCount;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
{ id: "folder", label: "文件夹", icon: <Folder />, badge: "6 款", section: "node", defaultOrder: 45, run: (ctx) => ctx.handlers.onAddFolder() },
|
||||
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
nodeCommand(CanvasNodeType.MediaConversion, { badge: "本地", defaultOrder: 65, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.MediaConversion) }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AudioLines, Captions, Clapperboard, Download, FolderPlus, Images, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Scissors, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
|
||||
import { AudioLines, Captions, Clapperboard, Download, FolderPlus, Images, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Scissors, Settings2, Trash2, Unlock, Upload, UserRound, Video, WandSparkles } from "lucide-react";
|
||||
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
|
||||
@@ -139,6 +139,19 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
|
||||
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || hasAudio(ctx),
|
||||
run: (ctx) => ctx.handlers.onNodeDownload(ctx.node!),
|
||||
},
|
||||
{
|
||||
id: "createConversion",
|
||||
toolbar: "node-hover",
|
||||
category: "node-state",
|
||||
label: "创建转换节点",
|
||||
displayLabel: "转换",
|
||||
icon: <WandSparkles className="size-3.5" />,
|
||||
defaultVisible: true,
|
||||
defaultOrder: 65,
|
||||
nodeToolbar: { group: "primary", order: 15, description: "在右侧创建转换节点并自动连接" },
|
||||
applicable: (ctx) => (hasImage(ctx) || hasVideo(ctx)) && Boolean(ctx.handlers.onNodeCreateConversion),
|
||||
run: (ctx) => ctx.handlers.onNodeCreateConversion?.(ctx.node!),
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
toolbar: "node-hover",
|
||||
|
||||
@@ -81,6 +81,8 @@ export type ToolbarHandlers = {
|
||||
onNodeUpload: (node: CanvasNodeData) => void;
|
||||
onNodeDownload: (node: CanvasNodeData) => void;
|
||||
onNodeSaveAsset: (node: CanvasNodeData) => void;
|
||||
/** 从已有图片或视频创建并自动连接一个转换节点。 */
|
||||
onNodeCreateConversion?: (node: CanvasNodeData) => void;
|
||||
onNodeMaskEdit: (node: CanvasNodeData) => void;
|
||||
onNodeEmotion: (node: CanvasNodeData) => void;
|
||||
onNodePortraitTexture: (node: CanvasNodeData) => void;
|
||||
|
||||
Reference in New Issue
Block a user