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

@@ -67,6 +67,7 @@ import { getProject } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
@@ -124,6 +125,7 @@ import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
import { useCanvasDirector } from "./use-canvas-director";
import { useCanvasGeneration } from "./use-canvas-generation";
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
import { useCanvasBatchTable } from "./use-canvas-batch-table";
import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
import { useCanvasHistory } from "./use-canvas-history";
@@ -1016,6 +1018,7 @@ function InfiniteCanvasPage() {
createConnectedNode,
getConnectionCreateDisabledReason,
handleConnectStart,
handleConnectDrop,
handleBatchConnectionTargetClick,
batchConnectionPreview,
beginBatchConnectionMode,
@@ -1842,6 +1845,15 @@ function InfiniteCanvasPage() {
handleGenerateNode,
});
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, patchTable: patchBatchTable, removeRow: removeBatchRow, updateRow: updateBatchRow } = useCanvasBatchTable({
nodesRef,
connectionsRef,
setNodes,
setConnections,
setSelectedNodeIds,
enqueueGenerationBatch,
});
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
useCanvasStoryboard({
projectId,
@@ -2036,6 +2048,27 @@ function InfiniteCanvasPage() {
if (contentNode.metadata?.workflowKind === "story_input") {
return <CanvasStoryInputNodeContent node={contentNode} onEdit={() => openStoryInput(contentNode.id)} />;
}
if (contentNode.type === CanvasNodeType.BatchTable) {
return (
<CanvasBatchTableNodeContent
node={contentNode}
nodes={nodesRef.current}
connections={connections}
batch={visibleGenerationBatch(contentNode)}
theme={theme}
onPatchTable={(patch) => patchBatchTable(contentNode.id, patch)}
onAddRow={() => addBatchRow(contentNode.id)}
onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)}
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
onFillRows={() => fillRowsFromConnections(contentNode.id)}
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
/>
);
}
if (contentNode.type === CanvasNodeType.Script) {
const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
return (
@@ -2104,17 +2137,23 @@ function InfiniteCanvasPage() {
);
},
[
addBatchReferenceColumn,
addBatchRow,
addScriptRow,
configInputsById,
connections,
createAndGenerateScriptVideos,
createScriptActionBoards,
createScriptImageNodes,
createScriptVideoNodes,
currentProject?.directorScenes,
fillRowsFromConnections,
generateBatchRows,
generateScriptImages,
generateScriptRows,
generateScriptVideos,
handleConfigNodeChange,
handleConnectDrop,
handleConnectStart,
handleGenerateNode,
handleNodeResize,
@@ -2122,10 +2161,14 @@ function InfiniteCanvasPage() {
mergeVideosByIds,
openDirectorWorkbench,
openStoryInput,
patchBatchTable,
removeBatchRow,
removeScriptRow,
retryFailedBatchItems,
runningNodeId,
stopRemainingBatchItems,
theme,
updateBatchRow,
updateScriptRow,
viewport.k,
workspaceMode,
@@ -2573,6 +2616,7 @@ function InfiniteCanvasPage() {
!isCanvasImageSourceNode(dialogNode) &&
!dialogNode.metadata?.fileUpload &&
dialogNode.type !== CanvasNodeType.Script &&
dialogNode.type !== CanvasNodeType.BatchTable &&
dialogNode.type !== CanvasNodeType.Drawing &&
dialogNode.type !== CanvasNodeType.Panorama &&
!selectionBox &&
@@ -2699,7 +2743,7 @@ function InfiniteCanvasPage() {
onDelete={(node) => deleteNodes(new Set([node.id]))}
/>
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
{!focusMode ? (
<CanvasOverlayLayerContainer

View File

@@ -8,6 +8,7 @@ import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
@@ -17,6 +18,7 @@ import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BAC
import { canvasThemes } from "@/lib/canvas-theme";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { getPublicCanvasShare } from "@/services/api/canvas-share";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas";
@@ -233,7 +235,11 @@ export default function SharedCanvasPage() {
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
};
}));
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => node.type === CanvasNodeType.Script ? <SharedScriptNode node={node} onUnauthorized={unauthorized} /> : <SharedConfigNode node={node} onUnauthorized={unauthorized} />, [unauthorized]);
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => {
if (node.type === CanvasNodeType.Script) return <SharedScriptNode node={node} onUnauthorized={unauthorized} />;
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onConnectStart={() => {}} />;
return <SharedConfigNode node={node} onUnauthorized={unauthorized} />;
}, [connections, nodes, theme, unauthorized]);
const toolbarNodeKey = selectedNodeId;
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;

View File

@@ -0,0 +1,153 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { batchInputColumns, batchReferenceColumns, createBatchRowsFromColumns, createInheritedBatchRow } from "@/lib/canvas/canvas-batch-table";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { navigateToSettings } from "@/lib/settings-navigation";
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
type Options = {
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
};
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, rows: [], ...node.metadata?.batchTable, ...patch } } }));
}, [setNodes]);
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node?.metadata?.batchTable) return;
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
}, [nodesRef, patchTable]);
const addRow = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: [...table.rows, createInheritedBatchRow(table.operation, table.rows)] });
}, [nodesRef, patchTable]);
const removeRow = useCallback((nodeId: string, rowId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
}, [nodesRef, patchTable]);
const addReferenceColumn = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const columns = batchReferenceColumns(table);
if (columns.length >= 6) return message.info("最多支持 6 组参考图");
const nextIndex = columns.length + 1;
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
}, [message, nodesRef, patchTable]);
const fillRowsFromConnections = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const table = node?.metadata?.batchTable;
if (!node || !table) return;
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
const input = nodeById.get(inputNodeId);
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
}));
if (!columns.some((column) => column.length)) return message.warning("请先把图片节点连接到批量创作表");
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
if (!rows.length) return message.warning("批量换装至少需要一张人物图和一张服装图");
patchTable(nodeId, { rows });
const added = Math.max(0, rows.length - table.rows.length);
message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
}, [connectionsRef, message, nodesRef, patchTable]);
const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => {
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
const table = sourceNode?.metadata?.batchTable;
if (!sourceNode || !table) return;
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
navigateToSettings({ continueCreation: true });
return;
}
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
const rows = table.rows.filter((row) => {
if (!row.enabled || (requested && !requested.has(row.id)) || !row.prompt.trim()) return false;
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
if (output && activeNodeIds.has(output.id)) return false;
// 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。
if (requested) return true;
return !output?.metadata?.content;
});
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
const confirmed = await new Promise<boolean>((resolve) => modal.confirm({
title: `确认提交 ${rows.length} 个批量图片任务`,
content: `模型:${modelDisplayName(effectiveConfig, imageModel)};并发上限:${table.concurrency}。这些任务可能消耗积分或产生外部模型费用。`,
okText: "确认生成",
cancelText: "取消",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
}));
if (!confirmed) return;
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const nextNodes = [...nodesRef.current];
let nextConnections = [...connectionsRef.current];
const outputByRowId = new Map<string, string>();
const targets: Array<{ rowId: string; nodeId: string }> = [];
rows.forEach((row, index) => {
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
const metadata = {
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
prompt: row.prompt.trim(),
composerContent: row.prompt.trim(),
model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
generationMode: "image" as const,
generationType: "edit" as const,
workflowKind: "final" as const,
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
status: "idle" as const,
batchSourceNodeId: nodeId,
batchRowId: row.id,
batchOperation: table.operation,
batchInputNodeIds: row.inputNodeIds,
};
const output = existingIndex >= 0
? { ...nextNodes[existingIndex], metadata }
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
if (existingIndex >= 0) nextNodes[existingIndex] = output;
else nextNodes.push(output);
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
outputByRowId.set(row.id, output.id);
targets.push({ rowId: row.id, nodeId: output.id });
});
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, patchTable, removeRow, updateRow };
}

View File

@@ -6,6 +6,7 @@ import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { batchReferenceHandleAtY } from "@/lib/canvas/canvas-batch-table";
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
@@ -227,7 +228,7 @@ export function useCanvasConnectionController({
setContextMenu(null);
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
@@ -319,8 +320,7 @@ export function useCanvasConnectionController({
setConnections(nextConnections);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
const skippedCount = batchPlan.skipped.length;
const duplicateCount = batchPlan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
@@ -386,13 +386,12 @@ export function useCanvasConnectionController({
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Config) {
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return "RunningHub 工作流插件未启用";
@@ -426,8 +425,8 @@ export function useCanvasConnectionController({
.reverse()
.forEach((node) => {
const scrollTop = scriptScrollTopById[node.id] || 0;
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined;
if (node.type === CanvasNodeType.Script && !targetHandleId) return;
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
// Ordinary nodes expose one centered input/output port. Only
// storyboard rows have a meaningful vertical target position.
const targetAnchorRatio = undefined;
@@ -473,8 +472,8 @@ export function useCanvasConnectionController({
.reverse()
.forEach((node) => {
const scrollTop = scriptScrollTopById[node.id] || 0;
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined;
if (node.type === CanvasNodeType.Script && !targetHandleId) return;
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
const targetAnchorRatio = undefined;
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
const dx = world.x - anchor.x;
@@ -646,12 +645,21 @@ export function useCanvasConnectionController({
// reset, an old quick-create menu can coexist with the new draft line;
// pointerup then sees the stale pending menu and silently aborts.
if (pendingConnectionCreateRef.current) closeConnectionCreateMenu();
if (connectingParamsRef.current) setConnecting(null);
if (batchConnectionPreviewRef.current && handleType === "target") {
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio);
clearBatchConnection();
return;
}
// A batch table exposes real target pins inside its content. When a normal
// image output is dragged onto one, commit that active connection instead
// of starting a new connection session from the target pin.
const activeConnection = connectingParamsRef.current;
if (activeConnection && handleType === "target" && activeConnection.nodeId !== nodeId) {
connectNodes(activeConnection, nodeId, handleId, anchorRatio);
setConnecting(null);
return;
}
if (activeConnection) setConnecting(null);
if (batchConnectionPreviewRef.current) clearBatchConnection();
connectingPointerIdRef.current = event.pointerId;
connectingPointerStartRef.current = { x: event.clientX, y: event.clientY };
@@ -660,7 +668,21 @@ export function useCanvasConnectionController({
setConnectionTargetNodeId(null);
setConnectionTargetAnchorRatio(undefined);
setSelectedConnectionId(null);
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]);
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, connectNodes, screenToCanvas, setConnecting, setSelectedConnectionId]);
const handleConnectDrop = useCallback((event: ReactPointerEvent, nodeId: string, handleId?: string) => {
event.preventDefault();
event.stopPropagation();
if (batchConnectionPreviewRef.current) {
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId);
clearBatchConnection();
return;
}
const current = connectingParamsRef.current;
if (!current || current.nodeId === nodeId) return;
connectNodes(current, nodeId, handleId);
setConnecting(null);
}, [clearBatchConnection, commitBatchConnection, connectNodes, setConnecting]);
useEffect(() => {
const cancelPendingPointerMove = () => {
@@ -799,6 +821,7 @@ export function useCanvasConnectionController({
createConnectedNode,
getConnectionCreateDisabledReason,
handleConnectStart,
handleConnectDrop,
handleBatchConnectionTargetClick,
batchConnectionPreview,
beginBatchConnectionMode,

View File

@@ -56,7 +56,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
);
const enqueueGenerationBatch = useCallback(
(sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: BatchTarget[]) => {
(sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: BatchTarget[], options?: { concurrency?: number }) => {
const sourceNode = nodesRef.current.find((node) => node.id === sourceNodeId);
if (!sourceNode || !targets.length) return;
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
@@ -73,6 +73,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
mode,
status: "queued",
items: availableTargets.map((target) => ({ id: nanoid(), ...target, status: "waiting", retryCount: 0 })),
concurrency: options?.concurrency ? Math.max(1, Math.min(10, Math.floor(options.concurrency))) : undefined,
createdAt: now,
updatedAt: now,
};
@@ -181,14 +182,18 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
for (const sourceNode of currentNodes) {
for (const batch of sourceNode.metadata?.generationBatches || []) {
if (batch.projectId !== projectId || batch.status === "completed" || batch.status === "cancelled") continue;
let batchAvailableSlots = batch.concurrency
? Math.max(0, batch.concurrency - batch.items.filter((item) => ["submitting", "queued", "running"].includes(item.status)).length)
: Number.POSITIVE_INFINITY;
for (const item of batch.items) {
if (item.status !== "waiting" || availableSlots <= 0) continue;
if (item.status !== "waiting" || availableSlots <= 0 || batchAvailableSlots <= 0) continue;
const node = nodeById.get(item.nodeId);
if (!node) continue;
// 已绑定任务或已有成品的节点交给恢复/对账链路处理,绝不重复提交。
if (node.metadata?.taskId || (node.metadata?.status === "success" && node.metadata.content)) continue;
candidates.push({ batch, item, node });
availableSlots -= 1;
batchAvailableSlots -= 1;
}
}
}

View File

@@ -150,8 +150,7 @@ export function useCanvasNodeOperations({
if (workflowTitle) node.title = workflowTitle;
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type === CanvasNodeType.Script) setDialogNodeId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.BatchTable && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== CanvasNodeType.MediaConversion) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {