feat(canvas): 多模态表格 - 合并 PR #549 并保留本地交互与校验

This commit is contained in:
ddCat
2026-09-20 17:18:46 +08:00
29 changed files with 3302 additions and 244 deletions

View File

@@ -61,6 +61,7 @@ import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-c
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { handleListGenerate } from "./list-mode-generator";
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
@@ -69,6 +70,7 @@ 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 { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
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";
@@ -94,7 +96,8 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
import { cinematicStoryboardColumns, createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
import { bindStoryboardKeyframes, storyboardKeyframeTime, storyboardRowsFromBatchTable } from "@/lib/canvas/canvas-batch-storyboard";
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
@@ -1925,7 +1928,25 @@ function InfiniteCanvasPage() {
handleGenerateNode,
});
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
const {
addReferenceColumn: addBatchReferenceColumn,
addRow: addBatchRow,
batchGenDialogConfig,
batchGenDialogConcurrency,
batchGenDialogOpen,
batchGenDialogRowCount,
closeBatchGenDialog,
confirmBatchGenDialog,
fillRowsFromConnections,
generateRows: generateBatchRows,
moveReferenceCell: moveBatchReferenceCell,
patchTable: patchBatchTable,
removeReferenceColumn: removeBatchReferenceColumn,
removeRow: removeBatchRow,
reorderReferenceColumns: reorderBatchReferenceColumns,
syncRowsFromConnections,
updateRow: updateBatchRow,
} = useCanvasBatchTable({
nodesRef,
connectionsRef,
setNodes,
@@ -1984,6 +2005,58 @@ function InfiniteCanvasPage() {
enqueueGenerationBatch,
});
const createStoryboardFromBatchTable = useCallback(async (tableNodeId: string) => {
const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable);
const table = tableNode?.metadata?.batchTable;
if (!tableNode || !table || table.contentKind !== "storyboard") return;
const rows = storyboardRowsFromBatchTable(table);
if (!rows.length) return message.warning("当前表格没有可用的分镜行");
const sourceIds = Array.from(new Set([
...(table.storyboardSourceNodeIds || []),
...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId),
])).filter((id) => nodesRef.current.some((node) => node.id === id));
const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video);
const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined);
const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length);
const scriptSpec = getNodeSpec(CanvasNodeType.Script);
const scriptNode = createCanvasNode(CanvasNodeType.Script, {
x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2,
y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2,
}, {
storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds },
storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct",
workflowKind: "storyboard",
workflowTitle: table.storyboardTitle || "视频脚本",
composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本",
status: "idle",
});
scriptNode.title = table.storyboardTitle || "视频脚本";
const nextConnections = [
...connectionsRef.current,
...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const })),
];
const nextNodes = [...nodesRef.current, scriptNode];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set([scriptNode.id]));
setDialogNodeId(null);
if (sourceVideo && keyframeTimesMs.length) {
const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs });
if (frameNodes.length) {
const withFrames = nodesRef.current.map((node) => node.id === scriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { ...node.metadata?.storyboard, rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id), visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds } } } : node);
nodesRef.current = withFrames;
setNodes(withFrames);
message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`);
} else {
message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取");
}
} else {
message.success(`已创建视频脚本:${rows.length} 个镜头`);
}
}, [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
const handleRetryNode = useCanvasGenerationRetry({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2134,11 +2207,26 @@ function InfiniteCanvasPage() {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
onListGenerate={(nodeId, listPrompt) => {
void handleListGenerate({
sourceNodeId: nodeId,
prompt: listPrompt,
nodes: nodesRef.current,
connections: connectionsRef.current,
config: effectiveConfig,
projectId,
setNodes,
setConnections,
setRunningNodeId,
setDialogNodeId,
});
}}
/>
);
},
[
configInputsById,
effectiveConfig,
handleConfigNodeChange,
handleGenerateNode,
handleNodePromptChange,
@@ -2150,6 +2238,10 @@ function InfiniteCanvasPage() {
message,
projectId,
runningNodeId,
setConnections,
setDialogNodeId,
setNodes,
setRunningNodeId,
skillMentionReferences,
workspaceMode,
],
@@ -2180,6 +2272,7 @@ function InfiniteCanvasPage() {
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
onFillRows={() => fillRowsFromConnections(contentNode.id)}
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
onCreateStoryboard={() => createStoryboardFromBatchTable(contentNode.id)}
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
@@ -2267,6 +2360,7 @@ function InfiniteCanvasPage() {
connections,
createAndGenerateScriptVideos,
createScriptActionBoards,
createStoryboardFromBatchTable,
createScriptImageNodes,
createScriptVideoNodes,
currentProject?.directorScenes,
@@ -3225,6 +3319,15 @@ function InfiniteCanvasPage() {
onConfirmClear={clearCanvas}
/>
<BatchGenerationSettingsDialog
open={batchGenDialogOpen}
config={batchGenDialogConfig}
rowCount={batchGenDialogRowCount}
concurrency={batchGenDialogConcurrency}
onClose={closeBatchGenDialog}
onConfirm={confirmBatchGenDialog}
/>
<AssetPickerModal open={assetPickerOpen} multiple={assetInsertScope === "canvas"} onInsert={handleLibraryAssetsInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal
open={projectAssetOpen}