import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { MAX_BATCH_REFERENCE_COLUMNS, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } 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 { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas"; import type { BatchGenerationSettings } from "@/components/canvas/batch-generation-settings-dialog"; type Options = { nodesRef: { current: CanvasNodeData[] }; connectionsRef: { current: CanvasConnection[] }; setNodes: Dispatch>; setConnections: Dispatch>; setSelectedNodeIds: Dispatch>>; enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined; }; type PendingBatchGen = { nodeId: string; rows: CanvasBatchRow[]; concurrency: number; }; export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) { const { message } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const [batchGenDialog, setBatchGenDialog] = useState<{ open: boolean; pending: PendingBatchGen | null }>({ open: false, pending: null }); const patchTable = useCallback((nodeId: string, patch: Partial) => { 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) => { 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, createBatchRow(table.operation)] }); }, [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 >= MAX_BATCH_REFERENCE_COLUMNS) return message.info("最多支持 10 组参考图"); const nextIndex = columns.length + 1; patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); }, [message, nodesRef, patchTable]); const removeReferenceColumn = useCallback((nodeId: string) => { const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; if (!table) return; const columns = batchReferenceColumns(table); const nextTable = removeLastBatchReferenceColumn(table); if (!nextTable) return message.info("至少保留 1 组参考图"); patchTable(nodeId, nextTable); const removed = columns.at(-1); if (removed) { const handleId = batchReferenceHandleId(removed.id); setConnections((current) => current.filter((connection) => !(connection.toNodeId === nodeId && connection.toHandleId === handleId))); } }, [message, nodesRef, patchTable, setConnections]); const addTextColumn = useCallback((nodeId: string) => { const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; if (!table) return; const columns = table.textColumns || []; if (columns.length >= 4) return message.info("最多支持 4 组文字"); patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] }); }, [message, nodesRef, patchTable]); const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { const node = nodesRef.current.find((item) => item.id === nodeId); const table = node?.metadata?.batchTable; if (!node || !table) return false; 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)) { if (!silent) message.warning("请先把图片节点连接到批量创作表"); return false; } const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { const input = nodeById.get(inputNodeId); return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt); })); const rowsWithText = rows.map((row, index) => ({ ...row, textNodeIds: textColumns.flatMap((column) => { const input = column.length === 1 ? column[0] : column[index]; return input ? [input] : []; }), })); if (!rows.length) { if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); return false; } const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText); if (!rowsChanged) return false; patchTable(nodeId, { rows: rowsWithText }); if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`); return true; }, [connectionsRef, message, nodesRef, patchTable]); const fillRowsFromConnections = useCallback((nodeId: string) => { syncRowsFromConnections(nodeId); }, [syncRowsFromConnections]); const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => { const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; if (!table) return; const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId); if (nextTable !== table) patchTable(nodeId, nextTable); }, [nodesRef, patchTable]); const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => { const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; if (!table) return; const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex); if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows }); }, [nodesRef, patchTable]); const executeBatchGeneration = useCallback((pending: PendingBatchGen, settings: BatchGenerationSettings) => { const { nodeId, rows, concurrency } = pending; const sourceNode = nodesRef.current.find((item) => item.id === nodeId); const table = sourceNode?.metadata?.batchTable; if (!sourceNode || !table) return; const mergedConfig = { ...effectiveConfig, ...settings }; const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const nextNodes = [...nodesRef.current]; let nextConnections = [...connectionsRef.current]; const outputByRowId = new Map(); 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 prompt = batchPromptForRow(table, row).trim(); const metadata = { ...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}), prompt, composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => { const textNode = nodesRef.current.find((node) => node.id === textNodeId); return textNode?.metadata?.content || textNode?.metadata?.prompt || ""; })].filter(Boolean).join("\n\n"), model: buildGenerationConfig(mergedConfig, undefined, "image").model, size: mergedConfig.size, quality: mergedConfig.quality, transparentBackground: mergedConfig.transparentBackground, count: Number(mergedConfig.count) || 1, 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, cameraControl: settings.cameraControl, }; 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.filter(Boolean).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 })) message.success(`${targets.length} 个任务已加入并发队列`); }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]); const generateRows = useCallback((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 === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).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; return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id)); }); if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词"); setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency } }); }, [effectiveConfig, isAiConfigReady, message, nodesRef]); const closeBatchGenDialog = useCallback(() => { setBatchGenDialog({ open: false, pending: null }); }, []); const confirmBatchGenDialog = useCallback((settings: BatchGenerationSettings) => { if (batchGenDialog.pending) { executeBatchGeneration(batchGenDialog.pending, settings); } setBatchGenDialog({ open: false, pending: null }); }, [batchGenDialog.pending, executeBatchGeneration]); const dialogConfig = useMemo(() => effectiveConfig, [effectiveConfig]); return { addReferenceColumn, addTextColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow, batchGenDialogOpen: batchGenDialog.open, batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0, batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1, batchGenDialogConfig: dialogConfig, closeBatchGenDialog, confirmBatchGenDialog, }; }