From f9dc1c561b606c36ddda2cc37fde2db0ec2e0136 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 15:48:07 +0800 Subject: [PATCH] merge: preserve batch canvas enhancements on v1.5.4 --- backend/internal/app/model_router.go | 4 +- .../canvas/canvas-batch-table-node.tsx | 846 +++++++++++------- web/src/lib/canvas/canvas-batch-table.ts | 272 ++++-- .../pages/canvas/use-canvas-batch-table.ts | 432 +++++---- web/test/canvas-batch-table.test.ts | 239 +++-- 5 files changed, 1026 insertions(+), 767 deletions(-) diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index a222f56f5..0e0e047e4 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -274,7 +274,9 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM } constraint, declared := spec.Inputs[inputType] if !declared { - if count > 0 { + // Multimodal text models may accept reference images even when the + // provider catalog does not explicitly declare the image input. + if count > 0 && !(normalizeCapability(spec.Capability) == "text" && inputType == "image") { reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") } continue diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index 178df7586..a89700ea1 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,25 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { Button, Switch, Tooltip } from "antd"; -import { Image as ImageIcon, LoaderCircle, Minus, Play, Plus, Rows3, Trash2, Upload } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { Button, Input, Select, Switch, Tooltip } from "antd"; +import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react"; import { CachedResourceImage } from "@/components/cached-resource-image"; -import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; -import { - BATCH_REFERENCE_HANDLE_GAP, - BATCH_REFERENCE_HANDLE_TOP, - MAX_BATCH_REFERENCE_COLUMNS, - MIN_BATCH_REFERENCE_COLUMNS, - batchPromptForRow, - batchReferenceColumns, - batchReferenceHandleId, - batchReferenceMentionToken, -} from "@/lib/canvas/canvas-batch-table"; -import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; -type ReferenceCell = { rowId: string; columnIndex: number }; - type Props = { node: CanvasNodeData; nodes: CanvasNodeData[]; @@ -36,9 +23,12 @@ type Props = { onAddReferenceColumn: () => void; onRemoveReferenceColumn?: () => void; onFocusOutput?: (nodeId: string) => void; - onReorderReferenceColumns?: (fromColumnId: string, toColumnId: string) => void; - onMoveReferenceCell?: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; - onUploadReference?: (rowId: string, columnIndex: number, file: File) => void; + onAddTextColumn?: () => void; + onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void; + onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; + onReplaceReference?: (node: CanvasNodeData) => void; + onUploadReference: (rowId: string, columnIndex: number, file: File) => void; + onRemoveReference?: (rowId: string, columnIndex: number) => void; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; readOnly?: boolean; @@ -49,415 +39,589 @@ const OPERATION_OPTIONS = [ { value: "creative", label: "创意生图" }, ] satisfies Array<{ value: CanvasBatchOperation; label: string }>; -const CONCURRENCY_OPTIONS = [1, 5, 10] as const; +type ReferenceCell = { rowId: string; columnIndex: number }; -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn, onReorderReferenceColumns, onMoveReferenceCell, onUploadReference, onFocusOutput, onConnectStart, onConnectDrop, readOnly = false }: Props) { +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn: _onRemoveReferenceColumn, onFocusOutput: _onFocusOutput, onAddTextColumn = () => {}, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference = () => {}, onUploadReference, onRemoveReference = () => {}, onConnectStart, onConnectDrop, readOnly = false }: Props) { const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; const referenceColumns = batchReferenceColumns(table); + const textColumns = batchTextColumns(table); const globalPrompt = table.globalPrompt || ""; const hasGlobalPrompt = Boolean(globalPrompt.trim()); const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); - const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)).size, [connections, node.id]); - const completed = table.rows.filter((row) => hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length; - const unfinishedReadyCount = table.rows.filter((row) => rowReady(row, table, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length; - const gridTemplateColumns = `64px repeat(${referenceColumns.length}, 88px) minmax(280px, 1fr) 88px 80px`; - const subtleSurface = `color-mix(in srgb, ${theme.node.text} 4%, transparent)`; - const inputSurface = theme.node.panel; - const fileInputRef = useRef(null); - const uploadTargetRef = useRef(null); + const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); + const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; + const isAiTable = Boolean(table.aiGenerated); + const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); const [draggingColumnId, setDraggingColumnId] = useState(null); const [draggingCell, setDraggingCell] = useState(null); + const [dropCell, setDropCell] = useState(null); const draggingCellRef = useRef(null); + const dropCellRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const hoveredCellRef = useRef(null); const suppressClickRef = useRef(false); + const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); + const uploadInputRef = useRef(null); const clearReferenceDrag = useCallback(() => { dragStartRef.current = null; draggingCellRef.current = null; + dropCellRef.current = null; setDraggingCell(null); + setDropCell(null); }, []); const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { - const target = document.elementFromPoint(clientX, clientY)?.closest("[data-batch-reference-cell]"); - const rowId = target?.dataset.rowId; - const columnIndex = Number(target?.dataset.columnIndex); + const hit = document.elementsFromPoint(clientX, clientY) + .map((element) => element.closest("[data-batch-reference-cell]")) + .find((element): element is HTMLElement => Boolean(element)); + if (!hit) return null; + const rowId = hit.dataset.batchRowId; + const columnIndex = Number(hit.dataset.batchColumnIndex); return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; }, []); - useEffect(() => { - if (readOnly) return; - const handleMove = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || start.pointerId !== event.pointerId) return; - const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); - if (!draggingCellRef.current && distance < 5) return; - if (!draggingCellRef.current) { - draggingCellRef.current = start.cell; - suppressClickRef.current = true; - setDraggingCell(start.cell); - } - }; - const handleUp = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || start.pointerId !== event.pointerId) return; - const sourceCell = draggingCellRef.current; - const targetCell = sourceCell ? findReferenceCellAtPoint(event.clientX, event.clientY) : null; - if (sourceCell && targetCell) onMoveReferenceCell?.(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); - const didDrag = Boolean(sourceCell); - clearReferenceDrag(); - if (didDrag) { - suppressClickRef.current = true; - window.setTimeout(() => { suppressClickRef.current = false; }, 0); - } - }; - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); - window.addEventListener("pointercancel", handleUp); - return () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - window.removeEventListener("pointercancel", handleUp); - }; - }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell, readOnly]); - - const startCellDrag = (event: ReactPointerEvent, rowId: string, columnIndex: number) => { - if (readOnly || event.button !== 0) return; - event.stopPropagation(); - dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; - }; - - const pickReferenceFile = (rowId: string, columnIndex: number) => { - if (readOnly) return; - if (suppressClickRef.current) { - suppressClickRef.current = false; - return; + const updateReferenceDrag = useCallback((clientX: number, clientY: number) => { + const start = dragStartRef.current; + if (!start || readOnly) return; + const distance = Math.hypot(clientX - start.x, clientY - start.y); + if (!draggingCellRef.current && distance < 5) return; + if (!draggingCellRef.current) { + draggingCellRef.current = start.cell; + setDraggingCell(start.cell); + suppressClickRef.current = true; } - uploadTargetRef.current = { rowId, columnIndex }; - fileInputRef.current?.click(); - }; + const nextDropCell = findReferenceCellAtPoint(clientX, clientY); + dropCellRef.current = nextDropCell; + setDropCell(nextDropCell); + }, [findReferenceCellAtPoint, readOnly]); + + const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => { + const start = dragStartRef.current; + if (!start) return; + const sourceCell = draggingCellRef.current; + const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY); + if (sourceCell && targetCell) { + onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); + } + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]); + + const cancelReferenceDrag = useCallback(() => { + if (!dragStartRef.current) return; + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag]); + + useEffect(() => { + const handleWindowPointerMove = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (distance >= 5) event.preventDefault(); + updateReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerUp = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + finishReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerCancel = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + cancelReferenceDrag(); + }; + const handleWindowMouseUp = () => { + if (!dragStartRef.current) return; + finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y); + }; + const handleWindowBlur = () => cancelReferenceDrag(); + window.addEventListener("pointermove", handleWindowPointerMove, { passive: false }); + window.addEventListener("pointerup", handleWindowPointerUp, true); + window.addEventListener("pointercancel", handleWindowPointerCancel, true); + window.addEventListener("mouseup", handleWindowMouseUp, true); + window.addEventListener("blur", handleWindowBlur); + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp, true); + window.removeEventListener("pointercancel", handleWindowPointerCancel, true); + window.removeEventListener("mouseup", handleWindowMouseUp, true); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + + // Delete / Backspace to remove hovered reference + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const cell = hoveredCellRef.current; + if (!cell || readOnly) return; + event.preventDefault(); + event.stopPropagation(); + onRemoveReference(cell.rowId, cell.columnIndex); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [readOnly, onRemoveReference]); + + + const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { + if (readOnly || !hasMaterial) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; + suppressClickRef.current = false; + }, [readOnly]); + + const handleReferencePointerMove = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + updateReferenceDrag(event.clientX, event.clientY); + }, [updateReferenceDrag]); + + const handleReferencePointerUp = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + finishReferenceDrag(event.clientX, event.clientY); + }, [finishReferenceDrag]); + + const handleReferencePointerCancel = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + cancelReferenceDrag(); + }, [cancelReferenceDrag]); return ( -
- {!readOnly ? { - const file = event.currentTarget.files?.[0]; - const target = uploadTargetRef.current; - event.currentTarget.value = ""; - uploadTargetRef.current = null; - if (file && target) onUploadReference?.(target.rowId, target.columnIndex, file); - }} /> : null} - {!readOnly ? : null} - -
-
-
event.stopPropagation()}> - onPatchTable({ operation: operation as CanvasBatchOperation })} /> -
- 并发 - ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} /> -
-
- {referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考 - {!readOnly ? ( - <> - - - - = MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}> - - - - ) : null} -
- 已连 {connectedImageCount} · 完成 {completed}/{table.rows.length} +
event.stopPropagation()} + onMouseDown={(event) => { + // Only the top padding is a node drag surface; keep child controls isolated. + if (event.target !== event.currentTarget) event.stopPropagation(); + }} + > + { + const file = event.currentTarget.files?.[0]; + const target = uploadTargetRef.current; + uploadTargetRef.current = null; + event.currentTarget.value = ""; + if (file && target) onUploadReference(target.rowId, target.columnIndex, file); + }} + /> + {!readOnly ? : null} +
event.stopPropagation()} + > + ({ value, label: String(value) }))} + disabled={readOnly} + onChange={(concurrency) => onPatchTable({ concurrency })} + className="w-20" + aria-label="并发数" + /> + + {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} + + {!readOnly ? ( +
+ 全局提示词 + onPatchTable({ globalPrompt: event.target.value })} + /> + {hasGlobalPrompt ? 已覆盖全部 : null}
- {!readOnly ? ( -
event.stopPropagation()}> - - - - - -
- ) : null} -
-
event.stopPropagation()}> - 全局提示词 - onPatchTable({ globalPrompt: event.target.value })} - /> -
+ ) : null} + {!readOnly ? ( +
+ + + +
+ ) : null}
-
event.stopPropagation()}> -
- 任务 +
event.stopPropagation()}> +
+ # + 任务 {referenceColumns.map((column) => ( - setDraggingColumnId(column.id)} - onDragEnd={() => setDraggingColumnId(null)} - onDragOver={(event) => { event.preventDefault(); }} - onDrop={() => { if (draggingColumnId) onReorderReferenceColumns?.(draggingColumnId, column.id); setDraggingColumnId(null); }} - > - {column.label} - + setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> ))} - 任务提示词 - 生成结果 - 操作 + {textColumns.map((column, index) => ( + {column.label} + ))} + 任务提示词 + 生成结果 +
- {table.rows.length ? ( table.rows.map((row, index) => { const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; const item = batchItemByRowId.get(row.id); - const status = row.enabled ? rowStatus(item, output) : { label: "已停用", tone: "idle" as const, loading: false, retryable: false }; - const ready = rowReady(row, table, nodeById); - const completedRow = hasNodeMedia(output); - const references = batchRowMentionReferences(row, referenceColumns, nodeById); - const effectivePrompt = batchPromptForRow(table, row); - const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !effectivePrompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.filter(Boolean).length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : ""; + const status = rowStatus(item, output); return ( -
-
- {!readOnly ? onUpdateRow(row.id, { enabled })} /> : null} - {index + 1} +
+ {index + 1} +
+ onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> + {row.enabled === false ? "关" : "开"}
{referenceColumns.map((column, columnIndex) => ( -
- pickReferenceFile(row.id, columnIndex)} - onUploadFile={(file) => onUploadReference?.(row.id, columnIndex, file)} - onPointerDown={(event) => startCellDrag(event, row.id, columnIndex)} - /> + onRemoveReference(row.id, columnIndex)} + onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + /> + ))} + {textColumns.map((column, columnIndex) => ( +
+ {table.aiGenerated ? ( +
+ {row.cells?.[column.id] || ""} +
+ ) : ( + + )}
))} -
- onUpdateRow(row.id, { prompt })} - onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - /> -
- {readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"} + onUpdateRow(row.id, { prompt: event.target.value })} /> +
+ {output?.metadata?.content || output?.metadata?.storageKey ? ( + } + /> + ) : ( + + )} +
+
{status.label}
+ {!readOnly && status.retryable && batch && item ? ( + + ) : null}
-
-
- { if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }} - /> + {status.loading ? : null}
{!readOnly ? ( -
- -
- ) : } + +
); }) ) : ( -
-
-
-
还没有批量任务
-

把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。

- {!readOnly ? : null} -
-
+
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
)}
); } -function BatchChoiceGroup({ ariaLabel, options, value, onChange, theme, compact = false, disabled = false }: { ariaLabel: string; options: Array<{ value: string | number; label: string }>; value: string | number; onChange: (value: string | number) => void; theme: CanvasTheme; compact?: boolean; disabled?: boolean }) { +function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { return ( -
- {options.map((option) => { - const selected = option.value === value; - return ; - })} -
+ { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", column.id); + onDragStart(); + }} + onDragEnd={onDragEnd} + onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} + onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} + > + {!readOnly ? : null} + {column.label} + ); } -function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType; theme: CanvasTheme; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { - const commonStyle = { left: 0, width: 36, height: 36, transform: "translate(-50%, -50%)", transformOrigin: "center" }; - return <>{columns.map((column, index) => )}; +function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { + return ; } -function BatchReferenceHandle({ column, index, theme, commonStyle, onConnectStart, onConnectDrop }: { column: { id: string; label: string }; index: number; theme: CanvasTheme; commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { +function BatchReferenceHandles({ + columns, + textColumns, + theme, + onAdd, + onAddText, + onConnectStart, + onConnectDrop, +}: { + columns: ReturnType; + textColumns: ReturnType; + theme: CanvasTheme; + onAdd: () => void; + onAddText: () => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; +}) { + const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; + const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; + const textTop = batchTextHandleTop(columns.length); + return ( + <> + {columns.map((column, index) => ( + + ))} + {textColumns.map((column, index) => ( + + ))} + {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( + + + + ) : null} + {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( + + + + ) : null} + + ); +} + +function BatchReferenceHandle({ + column, + top, + badge, + theme, + commonStyle, + onConnectStart, + onConnectDrop, + handlePrefix = "reference", +}: { + column: { id: string; label: string }; + top: number; + badge: string; + theme: CanvasTheme; + commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + handlePrefix?: "reference" | "text"; +}) { const [hovered, setHovered] = useState(false); const [offset, setOffset] = useState({ x: 0, y: 0 }); - const handleId = batchReferenceHandleId(column.id); - const reset = useCallback(() => { setHovered(false); setOffset({ x: 0, y: 0 }); }, []); + const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); + const reset = useCallback(() => { + setHovered(false); + setOffset({ x: 0, y: 0 }); + }, []); const update = useCallback((event: ReactPointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); const dx = event.clientX - (bounds.left + bounds.width / 2); const dy = event.clientY - (bounds.top + bounds.height / 2); - const limit = 10; + const limit = 16; setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); }, []); return ( - - ); } - -function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType, nodeById: Map): CanvasResourceReference[] { - return columns.flatMap((column, index) => { - const source = nodeById.get(row.inputNodeIds[index]); - if (!source) return []; - return [{ id: `${row.id}:${column.id}:${source.id}`, nodeId: source.id, kind: "image" as const, label: `参考图${index + 1}`, title: `${column.label} · ${source.title || "图片"}`, previewUrl: source.metadata?.previewContent || source.metadata?.content, storageKey: source.metadata?.storageKey, active: true, sourceType: source.type, mentionToken: batchReferenceMentionToken(index) }]; - }); +function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { + const content = node?.metadata?.content || node?.metadata?.prompt; + return
{content || "未连接文字"}
; } -function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex, isDraggingCell, onPickFile, onUploadFile, onPointerDown }: { node?: CanvasNodeData; label: string; theme: CanvasTheme; readOnly: boolean; rowId: string; columnIndex: number; isDraggingCell: boolean; onPickFile: () => void; onUploadFile: (file: File) => void; onPointerDown: (event: ReactPointerEvent) => void }) { - const filled = node && hasNodeMedia(node); +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { + node?: CanvasNodeData; + theme: CanvasTheme; + readOnly: boolean; + rowId: string; + columnIndex: number; + columnId: string; + draggingColumnId: string | null; + isDraggingCell: boolean; + isDropTarget: boolean; + suppressClickRef: MutableRefObject; + hoveredCellRef: MutableRefObject; + onReplace: (node: CanvasNodeData) => void; + onRemove: () => void; + onUpload: () => void; + onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + onLostPointerCapture: (event: ReactPointerEvent) => void; +}) { + const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; + const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; + if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { + return ( + + + + ); + } const fallback = ; return ( - + + ) : null} ); } -function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNodeData; status: ReturnType; theme: CanvasTheme; onFocus: () => void }) { - const filled = hasNodeMedia(output); - const tone = statusColor(status.tone, theme.node.stroke); - const title = filled ? `${status.label} · 点击定位到画布节点` : status.label; +function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { return ( - - - - ); -} - -function statusColor(tone: RowStatusTone, fallback: string) { - if (tone === "success") return "var(--status-success)"; - if (tone === "error") return "var(--status-error)"; - if (tone === "loading") return "var(--status-loading)"; - return fallback; -} - -function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode { - const sizeClass = "size-full"; - return ( -
- {compact ? : ( - - - 上传 - - )} +
+
); } -function rowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodeById: Map) { - if (!row.enabled || !batchPromptForRow(table, row).trim()) return false; - const inputNodeIds = row.inputNodeIds.filter(Boolean); - if (table.operation === "try_on" && inputNodeIds.length < 2) return false; - if (!inputNodeIds.length) return false; - return inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id))); -} - -function hasNodeMedia(node?: CanvasNodeData) { - return Boolean(node?.metadata?.content || node?.metadata?.storageKey); -} - -type RowStatusTone = "success" | "error" | "loading" | "idle"; -type RowStatus = { label: string; tone: RowStatusTone; loading: boolean; retryable: boolean }; - -function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined): RowStatus { - if (hasNodeMedia(output)) return { label: "生成完成", tone: "success", loading: false, retryable: false }; - if (item?.status === "failed") return { label: item.errorDetails || "生成失败", tone: "error", loading: false, retryable: true }; - if (item?.status === "cancelled") return { label: "已停止", tone: "error", loading: false, retryable: false }; - if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", tone: "loading", loading: true, retryable: false }; - if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", tone: "error", loading: false, retryable: false }; - return { label: "待生成", tone: "idle", loading: false, retryable: false }; +function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { + if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; + if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; + if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; + if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) + return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; + if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; + return { label: "待生成", loading: false, retryable: false }; } diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index 5ee32d398..559acaeba 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -5,19 +5,91 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。"; export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。"; export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:"; -/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */ -export const BATCH_REFERENCE_HANDLE_TOP = 102; -export const BATCH_REFERENCE_HANDLE_GAP = 40; +export const BATCH_REFERENCE_HANDLE_TOP = 112; +export const BATCH_REFERENCE_HANDLE_GAP = 38; +export const MAX_BATCH_REFERENCE_COLUMNS = 10; +export const MAX_BATCH_TEXT_COLUMNS = 4; +export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:"; export const MIN_BATCH_REFERENCE_COLUMNS = 1; -export const MAX_BATCH_REFERENCE_COLUMNS = 6; const LEGACY_BATCH_TABLE_WIDTH = 900; -/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */ export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData { if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node; return { ...node, width: 1280, height: Math.max(node.height, 560) }; } +export function batchGridTemplateColumns(referenceCount: number, textCount: number) { + return [ + "36px", + "68px", + `repeat(${referenceCount}, 72px)`, + textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined, + "minmax(280px, 1fr)", + "148px", + "36px", + ].filter(Boolean).join(" "); +} + +export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData { + const columns = batchReferenceColumns(table); + const fromIndex = columns.findIndex((column) => column.id === fromColumnId); + const toIndex = columns.findIndex((column) => column.id === toColumnId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table; + + const nextColumns = [...columns]; + const [moved] = nextColumns.splice(fromIndex, 1); + nextColumns.splice(toIndex, 0, moved); + const nextRows = table.rows.map((row) => ({ + ...row, + inputNodeIds: nextColumns.map((column) => { + const oldIndex = columns.findIndex((item) => item.id === column.id); + return row.inputNodeIds[oldIndex] || ""; + }), + })); + + return { + ...table, + referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })), + rows: nextRows, + }; +} + +export function moveBatchReferenceCell( + table: CanvasBatchTableData, + sourceRowId: string, + sourceColumnIndex: number, + targetRowId: string, + targetColumnIndex: number, +): CanvasBatchTableData { + if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table; + if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table; + + const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1); + const sourceRow = table.rows.find((row) => row.id === sourceRowId); + const targetRow = table.rows.find((row) => row.id === targetRowId); + if (!sourceRow || !targetRow) return table; + + const nextRows = table.rows.map((row) => ({ + ...row, + inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""), + })); + const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!; + const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!; + const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex]; + if (!sourceNodeId) return table; + + const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex]; + nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId; + nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || ""; + + return { ...table, rows: nextRows }; +} + +// Reserve one slot for adding a reference and a separate gap before text inputs. +export function batchTextHandleTop(referenceCount: number) { + return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24; +} + export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { return [ { id: "reference-1", label: "参考图 1" }, @@ -29,45 +101,13 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { 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 removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null { const columns = batchReferenceColumns(table); if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null; @@ -79,47 +119,71 @@ export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): Can }; } -export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData { - const columns = batchReferenceColumns(table); - const fromIndex = columns.findIndex((column) => column.id === fromColumnId); - const toIndex = columns.findIndex((column) => column.id === toColumnId); - if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table; - const nextColumns = [...columns]; - const [moved] = nextColumns.splice(fromIndex, 1); - nextColumns.splice(toIndex, 0, moved); - return { - ...table, - referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })), - rows: table.rows.map((row) => ({ - ...row, - inputNodeIds: nextColumns.map((column) => { - const oldIndex = columns.findIndex((item) => item.id === column.id); - return row.inputNodeIds[oldIndex] || ""; - }), - })), - }; +export function batchReferenceHandleId(columnId: string) { + return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`; } -export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData { - if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table; - if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table; - const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1); - const sourceRow = table.rows.find((row) => row.id === sourceRowId); - const targetRow = table.rows.find((row) => row.id === targetRowId); - if (!sourceRow || !targetRow) return table; - const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") })); - const nextSource = nextRows.find((row) => row.id === sourceRowId)!; - const nextTarget = nextRows.find((row) => row.id === targetRowId)!; - const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex]; - if (!sourceNodeId) return table; - const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex]; - nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId; - nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || ""; - return { ...table, rows: nextRows }; +export function batchReferenceColumnId(handleId?: string) { + return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined; } -export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) { - return table.globalPrompt?.trim() || row.prompt; +export function batchTextColumns(table?: CanvasBatchTableData) { + return table?.textColumns || []; +} + +export function batchTextHandleId(columnId: string) { + return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`; +} + +export function batchTextColumnId(handleId?: string) { + return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined; +} + +export function batchTextHandleY(node: CanvasNodeData, handleId?: string) { + if (node.type !== "batch-table") return undefined; + const columnId = batchTextColumnId(handleId); + const columns = batchTextColumns(node.metadata?.batchTable); + const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1; + if (index < 0) return undefined; + return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP; +} + +export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) { + if (node.type !== "batch-table") return undefined; + const columns = batchTextColumns(node.metadata?.batchTable); + const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length; + let nearestIndex = -1; + let nearestDistance = Number.POSITIVE_INFINITY; + columns.forEach((_, index) => { + const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP)); + if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } + }); + return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined; +} +export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) { + if (node.type !== "batch-table") return undefined; + const textY = batchTextHandleY(node, handleId); + if (textY !== undefined) return textY; + 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 referenceColumns = batchReferenceColumns(node.metadata?.batchTable); + const textColumns = batchTextColumns(node.metadata?.batchTable); + const handles = [ + ...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })), + ...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })), + ]; + const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => { + const distance = Math.abs(worldY - (node.position.y + handle.y)); + return !current || distance < current.distance ? { id: handle.id, distance } : current; + }, null); + return nearest && nearest.distance <= hitRadius ? nearest.id : undefined; } export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) { @@ -135,20 +199,34 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne return result; } +export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) { + const columns = batchTextColumns(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 = batchTextColumnId(connection.toHandleId); + const index = columnId ? indexById.get(columnId) : undefined; + if (index === undefined || result[index].includes(connection.fromNodeId)) return; + result[index].push(connection.fromNodeId); + }); + return result; +} + +/** A non-empty global prompt takes precedence without destroying row-specific prompts. */ +export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) { + return table.globalPrompt?.trim() || row.prompt; +} + export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow { return { id: `batch-row-${nanoid()}`, enabled: true, inputNodeIds, + textNodeIds: [], 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. @@ -160,34 +238,32 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId])); } -/** - * Zip equally sized columns and broadcast singleton columns across every row. - * - * 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。 - * 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。 - */ +/** 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 previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : [])); + return 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 previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[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 } : {}), + ...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}), ...(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]); +export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) { + return rows.map((row, rowIndex) => ({ + ...row, + textNodeIds: columns.flatMap((column) => { + const input = column.length === 1 ? column[0] : column[rowIndex]; + return input ? [input] : []; + }), + })); } diff --git a/web/src/pages/canvas/use-canvas-batch-table.ts b/web/src/pages/canvas/use-canvas-batch-table.ts index 6a3c5c319..ef79ec187 100644 --- a/web/src/pages/canvas/use-canvas-batch-table.ts +++ b/web/src/pages/canvas/use-canvas-batch-table.ts @@ -1,58 +1,67 @@ -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, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, 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 { 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>; - 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) => { +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, 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]); - + }, [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}` }] }); + 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) => { @@ -61,152 +70,191 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon const columns = batchReferenceColumns(table); const nextTable = removeLastBatchReferenceColumn(table); if (!nextTable) return message.info("至少保留 1 组参考图"); - const removed = columns.at(-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 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); - if (!rows.length) { - if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); - return false; - } - if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true; - patchTable(nodeId, { rows }); - if (!silent) { - const added = Math.max(0, rows.length - table.rows.length); - message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留"); - } - 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 generationConfig = buildGenerationConfig(effectiveConfig, undefined, "image"); - const imageResolution = /^(1k|2k|4k)$/i.test(generationConfig.quality) ? generationConfig.quality.toUpperCase() : "由尺寸决定"; - 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) => { - const inputNodeIds = row.inputNodeIds.filter(Boolean); - if (!row.enabled || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; - if (table.operation === "try_on" && inputNodeIds.length < 2) return false; - if (!inputNodeIds.length || 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((resolve) => modal.confirm({ - title: `确认提交 ${rows.length} 个批量图片任务`, - content: [ - `模型:${modelDisplayName(effectiveConfig, generationConfig.model) || generationConfig.model}`, - `出图数量:${rows.length} 张(每行 1 张)`, - `尺寸:${generationConfig.size || "默认"}`, - `质量:${generationConfig.quality || "默认"}`, - `分辨率:${imageResolution}`, - `并发:${table.concurrency}`, - "这些任务可能消耗积分或产生外部模型费用。", - ].join("\n"), - 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, - model: generationConfig.model, - size: generationConfig.size, - quality: generationConfig.quality, - transparentBackground: generationConfig.transparentBackground, - 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, + + 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); + 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: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`); - }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]); - - return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow }; -} + 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, + }; +} diff --git a/web/test/canvas-batch-table.test.ts b/web/test/canvas-batch-table.test.ts index 8331eb070..dccf3e451 100644 --- a/web/test/canvas-batch-table.test.ts +++ b/web/test/canvas-batch-table.test.ts @@ -1,101 +1,95 @@ -import { describe, expect, test } from "bun:test"; - -import { - batchPromptForRow, - batchReferenceColumns, - batchReferenceHandleAtY, - batchReferenceHandleId, - batchReferenceMentionToken, - createBatchRowsFromColumns, - createBatchRowsFromInputs, - createInheritedBatchRow, - moveBatchReferenceCell, - promoteLegacyBatchTableSize, - removeLastBatchReferenceColumn, - reorderBatchReferenceColumns, - BATCH_REFERENCE_HANDLE_GAP, - BATCH_REFERENCE_HANDLE_TOP, - TRY_ON_BATCH_PROMPT, -} from "@/lib/canvas/canvas-batch-table"; -import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; -import { CanvasNodeType } from "@/types/canvas"; - -describe("batch creation table", () => { - test("creates one try-on row per person with the final image as the shared garment", () => { - const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]); - - expect(rows.map((row) => row.inputNodeIds)).toEqual([ - ["person-1", "garment"], - ["person-2", "garment"], - ["person-3", "garment"], - ]); - expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true); - }); - - test("creates one creative row per connected image", () => { - expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]); - }); - - test("broadcasts a singleton reference column across rows", () => { - expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([ - ["person-1", "garment"], - ["person-2", "garment"], - ]); - }); - - test("inherits the previous row references when adding a manual task", () => { - const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]); - - expect(row.inputNodeIds).toEqual(["person-1", "garment"]); - expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds); - expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT); - }); - - test("syncing connections preserves unmatched manual rows", () => { - const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" }; - const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]); - - expect(rows).toHaveLength(2); - expect(rows[1]).toEqual(manual); - }); - - test("syncing unchanged references keeps the row identity and linked output", () => { - const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" }; - const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]); - - expect(row).toEqual(existing); - }); - - test("uses stable prompt mention tokens for positional references", () => { - expect(batchReferenceMentionToken(0)).toBe("@参考图1"); - expect(batchReferenceMentionToken(1)).toBe("@参考图2"); - }); - - test("new canvas node has durable batch defaults", () => { +import { describe, expect, test } from "bun:test"; + +import { batchGridTemplateColumns, batchPromptForRow, batchReferenceHandleAtY, batchReferenceHandleId, batchTextHandleId, batchTextHandleTop, createBatchRowsFromColumns, createBatchRowsFromInputs, CREATIVE_BATCH_PROMPT, moveBatchReferenceCell, reorderBatchReferenceColumns, TRY_ON_BATCH_PROMPT } from "@/lib/canvas/canvas-batch-table"; +import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; +import { CanvasNodeType } from "@/types/canvas"; + +describe("batch creation table", () => { + test("creates one try-on row per person with the final image as the shared garment", () => { + const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]); + + expect(rows.map((row) => row.inputNodeIds)).toEqual([ + ["person-1", "garment"], + ["person-2", "garment"], + ["person-3", "garment"], + ]); + expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true); + }); + + test("creates one creative row per connected image", () => { + expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]); + }); + + test("broadcasts a singleton reference column across rows", () => { + expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([ + ["person-1", "garment"], + ["person-2", "garment"], + ]); + }); + + test("new canvas node has durable batch defaults", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + + expect(node.title).toBe("批量创作表"); + expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] }); + }); + test("keeps the third reference handle addressable", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 2 * 38)).toBe(batchReferenceHandleId("reference-3")); + }); + test("keeps the second reference handle addressable", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + const referenceColumns = [ + { id: "reference-1", label: "参考图 1" }, + { id: "reference-2", label: "参考图 2" }, + ]; + node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } }; + + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2")); + }); + + test("chooses the nearest reference handle when magnetic hit areas overlap", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - expect(node.title).toBe("批量创作表"); - expect(node.width).toBe(1280); - expect(node.height).toBe(560); - expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] }); + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2")); }); - test("promotes the previous 900-wide default without touching resized tables", () => { + test("keeps text handles separated from reference controls", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - const legacy = { ...node, width: 900, height: 520 }; - const resized = { ...node, width: 1100, height: 480 }; + node.metadata = { ...node.metadata, batchTable: { ...node.metadata?.batchTable!, textColumns: [{ id: "text-1", label: "文字 1" }] } }; - expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 }); - expect(promoteLegacyBatchTableSize(resized)).toBe(resized); + expect(batchReferenceHandleAtY(node, node.position.y + batchTextHandleTop(3))).toBe(batchTextHandleId("text-1")); + expect(batchTextHandleTop(3)).toBeGreaterThan(112 + 3 * 38); }); - test("keeps a single explicitly stored reference column", () => { + + test("global prompt overrides a row prompt without changing the row data", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - node.metadata = { ...node.metadata, batchTable: { operation: "creative", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }], rows: [] } }; + const table = node.metadata?.batchTable!; + const row = createBatchRowsFromInputs("creative", ["image-1"])[0]; - expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]); + expect(batchPromptForRow(table, row)).toBe(row.prompt); + expect(batchPromptForRow({ ...table, globalPrompt: "统一风格:高级商业摄影" }, row)).toBe("统一风格:高级商业摄影"); + expect(row.prompt).toBe(CREATIVE_BATCH_PROMPT); }); - test("removes the last reference column down to one and trims row inputs", () => { + test("legacy rows without enabled stay enabled by default", () => { + const row = createBatchRowsFromInputs("creative", ["image-1"])[0]; + expect(row.enabled).toBe(true); + expect({ ...row, enabled: undefined }.enabled === false).toBe(false); + }); + + test("keeps the table grid valid when no text columns exist", () => { + const withoutText = batchGridTemplateColumns(3, 0); + const withText = batchGridTemplateColumns(3, 1); + + expect(withoutText).not.toContain("repeat(0,"); + expect(withoutText).toContain("68px"); + expect(withoutText).toContain("repeat(3, 72px)"); + expect(withText).toContain("repeat(1, minmax(168px, 0.75fr))"); + }); + + test("reorders reference columns and keeps row materials aligned", () => { const table = { operation: "creative" as const, concurrency: 10, @@ -104,49 +98,16 @@ describe("batch creation table", () => { { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }, ], - rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "" }], }; + const reordered = reorderBatchReferenceColumns(table, "reference-1", "reference-3"); - const next = removeLastBatchReferenceColumn(table); - expect(next?.referenceColumns?.map((column) => column.id)).toEqual(["reference-1", "reference-2"]); - expect(next?.rows[0].inputNodeIds).toEqual(["a", "b"]); - expect(removeLastBatchReferenceColumn({ ...table, referenceColumns: [{ id: "reference-1", label: "参考图 1" }] })).toBeNull(); + expect(reordered.referenceColumns?.map((column) => column.label)).toEqual(["参考图 1", "参考图 2", "参考图 3"]); + expect(reordered.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]); + expect(reordered.rows[0].inputNodeIds).toEqual(["b", "c", "a"]); }); - test("keeps the second reference handle addressable", () => { - const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - const referenceColumns = [ - { id: "reference-1", label: "参考图 1" }, - { id: "reference-2", label: "参考图 2" }, - ]; - node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } }; - - expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP)).toBe(batchReferenceHandleId("reference-2")); - }); - - test("chooses the nearest reference handle when magnetic hit areas overlap", () => { - const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - - expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP, 90)).toBe(batchReferenceHandleId("reference-2")); - }); - - test("reorders reference columns and keeps row inputs aligned", () => { - const table = { - operation: "try_on" as const, - concurrency: 10, - referenceColumns: [ - { id: "reference-1", label: "参考图 1" }, - { id: "reference-2", label: "参考图 2" }, - { id: "reference-3", label: "参考图 3" }, - ], - rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], - }; - const next = reorderBatchReferenceColumns(table, "reference-1", "reference-3"); - expect(next.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]); - expect(next.rows[0].inputNodeIds).toEqual(["b", "c", "a"]); - }); - - test("swaps reference cells across rows and columns", () => { + test("moves a reference image into an empty slot across rows", () => { const table = { operation: "creative" as const, concurrency: 10, @@ -155,18 +116,26 @@ describe("batch creation table", () => { { id: "reference-2", label: "参考图 2" }, ], rows: [ - { id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "one" }, - { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" }, + { id: "row-1", enabled: true, inputNodeIds: ["a", ""], prompt: "" }, + { id: "row-2", enabled: true, inputNodeIds: ["b", ""], prompt: "" }, ], }; - const next = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); - expect(next.rows[0].inputNodeIds).toEqual(["d", "b"]); - expect(next.rows[1].inputNodeIds).toEqual(["c", "a"]); + + const moved = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); + + expect(moved.rows.map((row) => row.inputNodeIds)).toEqual([["", ""], ["b", "a"]]); }); - test("uses a non-empty global prompt instead of the row prompt", () => { - const table = { operation: "creative" as const, concurrency: 10, globalPrompt: " 全局覆盖 ", rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a"], prompt: "行提示词" }] }; - expect(batchPromptForRow(table, table.rows[0])).toBe("全局覆盖"); - expect(batchPromptForRow({ ...table, globalPrompt: " " }, table.rows[0])).toBe("行提示词"); + test("swaps reference images when the target slot is occupied", () => { + const table = { + operation: "creative" as const, + concurrency: 10, + referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "" }, { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "" }], + }; + + const swapped = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); + + expect(swapped.rows.map((row) => row.inputNodeIds)).toEqual([["d", "b"], ["c", "a"]]); }); });