diff --git a/web/src/components/canvas/batch-generation-settings-dialog.tsx b/web/src/components/canvas/batch-generation-settings-dialog.tsx new file mode 100644 index 000000000..49df76d5c --- /dev/null +++ b/web/src/components/canvas/batch-generation-settings-dialog.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { Button, Modal } from "antd"; +import { WandSparkles, X } from "lucide-react"; + +import { ImageSettingsPanel } from "@/components/image-settings-panel"; +import { ModelPicker } from "@/components/model-picker"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { defaultImageParamsForModel } from "@/lib/model-selection"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import type { AiConfig } from "@/stores/use-config-store"; + +export type BatchGenerationSettings = Pick; + +type BatchGenerationSettingsDialogProps = { + open: boolean; + config: AiConfig; + rowCount: number; + concurrency: number; + onClose: () => void; + onConfirm: (settings: BatchGenerationSettings) => void; +}; + +export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) { + const theme = canvasThemes[useActiveTheme()]; + const [generationConfig, setGenerationConfig] = useState(config); + + useEffect(() => { + if (open) setGenerationConfig(config); + }, [config, open]); + + const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => { + setGenerationConfig((current) => ({ ...current, [key]: value })); + }; + + const handleModelChange = (model: string) => { + setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) })); + }; + + const imageModel = generationConfig.imageModel || generationConfig.model; + + return ( + +
+
+ 共 {rowCount} 个未完成任务 · 并发上限 {concurrency} +
+ +
+
生成模型
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index d7bddfd0b..e61764fa9 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -1,4 +1,4 @@ -import { nanoid } from "nanoid"; +import { nanoid } from "nanoid"; import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasNodeData } from "@/types/canvas"; @@ -7,7 +7,7 @@ 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 const MAX_BATCH_REFERENCE_COLUMNS = 10; export const MAX_BATCH_TEXT_COLUMNS = 4; export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:"; @@ -15,7 +15,7 @@ export function batchGridTemplateColumns(referenceCount: number, textCount: numb return [ "36px", "68px", - `repeat(${referenceCount}, 88px)`, + `repeat(\${referenceCount}, 72px)`, textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined, "minmax(280px, 1fr)", "148px", diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 322ae02e4..e9961393a 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,4 +1,4 @@ -import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; +import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -110,6 +110,7 @@ import { TapNowImportDialog } from "./components/tapnow-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; +import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog"; import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar"; import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs"; import { CanvasProjectWorldLayers } from "./canvas-project-world-layers"; @@ -1910,7 +1911,7 @@ function InfiniteCanvasPage() { handleGenerateNode, }); - const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({ + const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow, batchGenDialogOpen, batchGenDialogRowCount, batchGenDialogConcurrency, batchGenDialogConfig, closeBatchGenDialog, confirmBatchGenDialog } = useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, @@ -3176,6 +3177,15 @@ function InfiniteCanvasPage() { config={effectiveConfig} /> + + >; - setConnections: Dispatch>; - setSelectedNodeIds: Dispatch>>; - 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) => { - setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, 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 >= 6) return message.info("最多支持 6 组参考图"); - const nextIndex = columns.length + 1; - patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); - }, [message, nodesRef, patchTable]); - - 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 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 === 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("没有可提交的未完成任务,请检查参考图和提示词"); - const confirmed = await new Promise((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(); - 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(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, addTextColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow }; -} +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, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, 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: 3, 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 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, + }; + 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 })) 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, + removeRow, + reorderReferenceColumns, + syncRowsFromConnections, + updateRow, + batchGenDialogOpen: batchGenDialog.open, + batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0, + batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1, + batchGenDialogConfig: dialogConfig, + closeBatchGenDialog, + confirmBatchGenDialog, + }; +}