feat(canvas): 画布节点 - 恢复批量创作表与转换能力

This commit is contained in:
ddCat
2026-09-15 20:56:19 +08:00
parent f59b59b3e6
commit c57f1b36ae
36 changed files with 3341 additions and 34 deletions

View File

@@ -0,0 +1,134 @@
import { nanoid } from "nanoid";
import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasNodeData } from "@/types/canvas";
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
export const BATCH_REFERENCE_HANDLE_TOP = 112;
export const BATCH_REFERENCE_HANDLE_GAP = 38;
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
return [
{ id: "reference-1", label: "参考图 1" },
{ id: "reference-2", label: "参考图 2" },
{ id: "reference-3", label: "参考图 3" },
];
}
export function batchReferenceColumns(table?: CanvasBatchTableData) {
const columns = table?.referenceColumns;
if (!columns?.length) return defaultBatchReferenceColumns();
// Older tables were created before the second default slot existed. They
// never had a remove-column action, so extending this exact legacy shape is
// a safe migration and makes reference slot 2 immediately selectable.
if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
return columns;
}
export function batchReferenceHandleId(columnId: string) {
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
}
export function batchReferenceMentionToken(index: number) {
return `@参考图${index + 1}`;
}
export function batchReferenceColumnId(handleId?: string) {
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
}
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
if (node.type !== "batch-table") return undefined;
const columnId = batchReferenceColumnId(handleId);
const columns = batchReferenceColumns(node.metadata?.batchTable);
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
if (index < 0) return undefined;
return node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP;
}
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
if (node.type !== "batch-table") return undefined;
const columns = batchReferenceColumns(node.metadata?.batchTable);
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
columns.forEach((_, index) => {
const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
}
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
const columns = batchReferenceColumns(node.metadata?.batchTable);
const indexById = new Map(columns.map((column, index) => [column.id, index]));
const result = columns.map(() => [] as string[]);
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
const columnId = batchReferenceColumnId(connection.toHandleId);
const index = columnId ? indexById.get(columnId) : 0;
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
result[index].push(connection.fromNodeId);
});
return result;
}
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
return {
id: `batch-row-${nanoid()}`,
enabled: true,
inputNodeIds,
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
};
}
/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
}
/**
* Connected try-on inputs follow the reference layout: all model/person images first,
* followed by one shared garment image. Users can still edit any row afterwards.
*/
export function createBatchRowsFromInputs(operation: CanvasBatchOperation, inputNodeIds: string[]) {
if (operation === "creative") return inputNodeIds.map((id) => createBatchRow(operation, [id]));
if (inputNodeIds.length < 2) return [];
const garmentId = inputNodeIds.at(-1)!;
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
}
/**
* Zip equally sized columns and broadcast singleton columns across every row.
*
* 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
* 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
*/
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
const rowCount = Math.max(0, ...columns.map((column) => column.length));
const unmatchedRows = [...previousRows];
const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
const inputNodeIds = columns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[rowIndex];
return input ? [input] : [];
});
let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
return {
...createBatchRow(operation, inputNodeIds),
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
inputNodeIds,
};
});
return [...synchronizedRows, ...unmatchedRows];
}
function sameBatchInputs(left: string[], right: string[]) {
return left.length === right.length && left.every((id, index) => id === right[index]);
}

View File

@@ -16,10 +16,14 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
if (acceptedInputKinds.length) {
const source = nodes.find((node) => node.id === candidate.fromNodeId);
const sourceKind = source ? getNodeInputKind(source.type) : undefined;
const hasAcceptedSource = Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
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 `${labels}节点只接受${labels}输入`;
const targetLabel = isMediaConversion ? "转换" : target.type === CanvasNodeType.BatchTable ? "批量创作表" : labels;
return `${targetLabel}节点只接受${labels}输入`;
}
const maxInputCount = getNodeMaxInputCount(target.type);
if (maxInputCount) {
@@ -28,7 +32,7 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
.filter((connection) => connection.toNodeId === target.id)
.map((connection) => connection.fromNodeId),
).size;
if (inputCount > maxInputCount) return `当前节点最多连接 ${maxInputCount} 个输入`;
if (inputCount > maxInputCount) return `${isMediaConversion ? "转换" : "当前"}节点最多连接 ${maxInputCount} 个输入`;
}
}
const mode = getNodeGenerationMode(target);
@@ -50,10 +54,11 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
return "";
}
function acceptedInputKindLabel(kind: "image" | "video" | "audio" | "text") {
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 "文本";
}
@@ -69,7 +74,7 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa
if (!inputKind) return;
// 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
else input[`${inputKind}Count`] += 1;
else if (inputKind !== "table_data") input[`${inputKind}Count`] += 1;
});
return input;
}

View File

@@ -9,6 +9,7 @@ import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
@@ -166,7 +167,7 @@ function resetGenerationParamsOnModelSwitch(node: CanvasNodeData, patch: Partial
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0) {
return {
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
y: batchReferenceHandleY(node, handleId) ?? storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
};
}

View File

@@ -232,6 +232,19 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
}
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type === CanvasNodeType.MediaConversion) {
// 转换结果只在成功物化后作为生成参考;运行中、跳过和失败状态都不能透传旧结果。
const conversion = node.metadata?.mediaConversion;
const storageKey = conversion?.resultStorageKey || node.metadata?.storageKey;
if (conversion?.status !== "completed" || !storageKey) return null;
return {
id: node.id,
name: node.title || `conversion-${node.id}.png`,
type: node.metadata?.mimeType || "image/png",
dataUrl: node.metadata?.content || "",
storageKey,
};
}
if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,

View File

@@ -1,4 +1,4 @@
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Type, Video, WandSparkles } from "lucide-react";
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Table2, Type, Video, WandSparkles } from "lucide-react";
import { NODE_SPECS } from "@/constant/canvas";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
@@ -154,6 +154,8 @@ const BUILTIN_NODE_TRAITS = {
showInCreateMenu: true,
inputKind: "text",
},
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 720, height: 360 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
[CanvasNodeType.ColorGrade]: {
label: "调色",
icon: <Palette />,

View File

@@ -5,7 +5,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNo
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
/** 作为上游输入被容量校验计数时归入的类别 */
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text" | "table_data";
/**
* 节点定义——注册表的基本单元。

View File

@@ -20,6 +20,8 @@ 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.BatchTable, { defaultOrder: 66, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.BatchTable) }),
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() }),