feat(canvas): 批量创作表 - 支持全局提示词、参考图编排与连线同步
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
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, Play, Plus, RefreshCw, Rows3, Trash2 } from "lucide-react";
|
||||
import { Image as ImageIcon, LoaderCircle, Minus, Play, Plus, Rows3, Trash2, Upload } from "lucide-react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
BATCH_REFERENCE_HANDLE_GAP,
|
||||
BATCH_REFERENCE_HANDLE_TOP,
|
||||
MAX_BATCH_REFERENCE_COLUMNS,
|
||||
MIN_BATCH_REFERENCE_COLUMNS,
|
||||
batchPromptForRow,
|
||||
batchReferenceColumns,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
@@ -16,6 +18,8 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer
|
||||
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[];
|
||||
@@ -30,6 +34,11 @@ type Props = {
|
||||
onGenerate: (rowIds?: string[]) => void;
|
||||
onRetryItem: (batchId: string, itemId: string) => void;
|
||||
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;
|
||||
onConnectStart: (event: ReactPointerEvent, handleId: string) => void;
|
||||
onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -42,83 +51,212 @@ const OPERATION_OPTIONS = [
|
||||
|
||||
const CONCURRENCY_OPTIONS = [1, 5, 10] as const;
|
||||
|
||||
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onConnectStart, onConnectDrop, readOnly = false }: Props) {
|
||||
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) {
|
||||
const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] };
|
||||
const referenceColumns = batchReferenceColumns(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.operation, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length;
|
||||
const gridTemplateColumns = `54px repeat(${referenceColumns.length}, 84px) minmax(280px, 1fr) 146px 76px`;
|
||||
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<HTMLInputElement>(null);
|
||||
const uploadTargetRef = useRef<ReferenceCell | null>(null);
|
||||
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
|
||||
const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null);
|
||||
const draggingCellRef = useRef<ReferenceCell | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
const clearReferenceDrag = useCallback(() => {
|
||||
dragStartRef.current = null;
|
||||
draggingCellRef.current = null;
|
||||
setDraggingCell(null);
|
||||
}, []);
|
||||
|
||||
const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => {
|
||||
const target = document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>("[data-batch-reference-cell]");
|
||||
const rowId = target?.dataset.rowId;
|
||||
const columnIndex = Number(target?.dataset.columnIndex);
|
||||
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;
|
||||
}
|
||||
uploadTargetRef.current = { rowId, columnIndex };
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-canvas-no-zoom data-canvas-wheel-scroll className="relative flex h-full w-full flex-col overflow-visible text-xs" style={{ color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div data-canvas-batch-table data-canvas-no-zoom data-canvas-wheel-scroll className="relative flex h-full w-full flex-col overflow-visible text-xs" style={{ color: theme.node.text }}>
|
||||
{!readOnly ? <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
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 ? <BatchReferenceHandles columns={referenceColumns} theme={theme} onConnectStart={onConnectStart} onConnectDrop={onConnectDrop} /> : null}
|
||||
|
||||
<div className="flex min-h-14 flex-wrap items-center gap-2 border-b px-3 py-2" style={{ borderColor: theme.node.stroke, background: subtleSurface }}>
|
||||
<BatchChoiceGroup ariaLabel="批量任务类型" theme={theme} disabled={readOnly} options={OPERATION_OPTIONS} value={table.operation} onChange={(operation) => onPatchTable({ operation: operation as CanvasBatchOperation })} />
|
||||
<div className="flex items-center gap-1.5 rounded-lg px-2 py-1" style={{ background: theme.node.panel }}>
|
||||
<span className="font-medium" style={{ color: theme.node.muted }}>并发</span>
|
||||
<BatchChoiceGroup ariaLabel="并发数" theme={theme} disabled={readOnly} compact options={CONCURRENCY_OPTIONS.map((value) => ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} />
|
||||
</div>
|
||||
<div className="flex h-7 items-center gap-1 rounded-lg px-2" style={{ background: theme.node.panel, color: theme.node.muted }}>
|
||||
<span>{referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考</span>
|
||||
<div className="shrink-0 overflow-hidden rounded-t-[inherit] border-b" style={{ borderColor: theme.node.stroke, background: subtleSurface }}>
|
||||
<div data-canvas-batch-drag className="flex h-11 cursor-grab items-center gap-2 px-3 active:cursor-grabbing">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<BatchChoiceGroup ariaLabel="批量任务类型" theme={theme} disabled={readOnly} options={OPERATION_OPTIONS} value={table.operation} onChange={(operation) => onPatchTable({ operation: operation as CanvasBatchOperation })} />
|
||||
<div className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2" style={{ background: theme.node.panel }}>
|
||||
<span className="font-medium" style={{ color: theme.node.muted }}>并发</span>
|
||||
<BatchChoiceGroup ariaLabel="并发数" theme={theme} disabled={readOnly} compact options={CONCURRENCY_OPTIONS.map((value) => ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center gap-0.5 rounded-lg px-2" style={{ background: theme.node.panel, color: theme.node.muted }}>
|
||||
<span className="pr-1">{referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考</span>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Tooltip title={referenceColumns.length <= MIN_BATCH_REFERENCE_COLUMNS ? "至少保留 1 组参考图" : "减少一组参考图"}>
|
||||
<button type="button" aria-label="减少一组参考图" className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length <= MIN_BATCH_REFERENCE_COLUMNS} onClick={onRemoveReferenceColumn}>
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}>
|
||||
<button type="button" aria-label={`新增参考图 ${referenceColumns.length + 1}`} className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS} onClick={onAddReferenceColumn}>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums" style={{ color: theme.node.muted }}>已连 {connectedImageCount} · 完成 {completed}/{table.rows.length}</span>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}>
|
||||
<button type="button" aria-label={`新增参考图 ${referenceColumns.length + 1}`} className="grid size-5 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10" style={{ color: theme.node.text }} disabled={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS} onClick={onAddReferenceColumn}>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Tooltip title="增量同步画布连线,不会删除已有任务行">
|
||||
<Button size="small" type="text" icon={<Rows3 className="size-3.5" />} onClick={onFillRows}>同步连线</Button>
|
||||
</Tooltip>
|
||||
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加任务</Button>
|
||||
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!unfinishedReadyCount} onClick={() => onGenerate()}>
|
||||
生成未完成项{unfinishedReadyCount ? ` · ${unfinishedReadyCount}` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="tabular-nums" style={{ color: theme.node.muted }}>已连 {connectedImageCount} · 完成 {completed}/{table.rows.length}</span>
|
||||
|
||||
{!readOnly ? (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Tooltip title="增量同步画布连线,不会删除已有任务行">
|
||||
<Button size="small" type="text" icon={<Rows3 className="size-3.5" />} onClick={onFillRows}>同步连线</Button>
|
||||
</Tooltip>
|
||||
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={onAddRow}>添加任务</Button>
|
||||
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!unfinishedReadyCount} onClick={() => onGenerate()}>
|
||||
生成未完成项{unfinishedReadyCount ? ` · ${unfinishedReadyCount}` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-9 items-center gap-2 border-t px-3" style={{ borderColor: theme.node.stroke }} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="shrink-0 font-medium" style={{ color: theme.node.muted }}>全局提示词</span>
|
||||
<input
|
||||
value={globalPrompt}
|
||||
readOnly={readOnly}
|
||||
placeholder="填写后覆盖各任务提示词,留空则使用每行自己的提示词"
|
||||
aria-label="全局提示词"
|
||||
className="h-7 min-w-0 flex-1 rounded-md border px-2.5 text-xs outline-none"
|
||||
style={{ background: inputSurface, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(event) => onPatchTable({ globalPrompt: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto rounded-b-[inherit]">
|
||||
<div className="sticky top-0 z-10 grid min-w-[820px] items-center border-b px-2 py-2 text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.muted, gridTemplateColumns }}>
|
||||
<span className="pl-1">任务</span>
|
||||
{referenceColumns.map((column, index) => (
|
||||
<span key={column.id} className="flex min-w-0 flex-col leading-4">
|
||||
<span className="truncate">{column.label}</span>
|
||||
<span className="font-normal opacity-50">{batchReferenceMentionToken(index)}</span>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto rounded-b-[inherit]" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="sticky top-0 z-10 grid h-9 items-center border-b px-3 text-center text-[11px] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.muted, gridTemplateColumns }}>
|
||||
<span className="min-w-0 truncate px-1">任务</span>
|
||||
{referenceColumns.map((column) => (
|
||||
<span
|
||||
key={column.id}
|
||||
draggable={!readOnly}
|
||||
title={column.label}
|
||||
className="min-w-0 cursor-grab truncate px-1 active:cursor-grabbing"
|
||||
style={{ opacity: draggingColumnId === column.id ? 0.45 : 1 }}
|
||||
onDragStart={() => setDraggingColumnId(column.id)}
|
||||
onDragEnd={() => setDraggingColumnId(null)}
|
||||
onDragOver={(event) => { event.preventDefault(); }}
|
||||
onDrop={() => { if (draggingColumnId) onReorderReferenceColumns?.(draggingColumnId, column.id); setDraggingColumnId(null); }}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
))}
|
||||
<span>任务提示词</span>
|
||||
<span>生成结果</span>
|
||||
<span className="text-center">操作</span>
|
||||
<span className="min-w-0 truncate px-1">任务提示词</span>
|
||||
<span className="min-w-0 truncate px-1">生成结果</span>
|
||||
<span className="min-w-0 truncate px-1">操作</span>
|
||||
</div>
|
||||
|
||||
{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: "已停用", loading: false, retryable: false };
|
||||
const ready = rowReady(row, table.operation, nodeById);
|
||||
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 disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !row.prompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : "";
|
||||
const effectivePrompt = batchPromptForRow(table, row);
|
||||
const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !effectivePrompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.filter(Boolean).length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : "";
|
||||
return (
|
||||
<div key={row.id} className="group grid min-w-[820px] items-center gap-2 border-b px-2 py-2.5 transition-colors hover:bg-black/[.025] dark:hover:bg-white/[.025]" style={{ borderColor: theme.node.stroke, gridTemplateColumns, opacity: row.enabled ? 1 : 0.58 }}>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<div key={row.id} className="group grid items-center border-b px-3 py-3 transition-colors hover:bg-black/[.025] dark:hover:bg-white/[.025]" style={{ borderColor: theme.node.stroke, gridTemplateColumns, opacity: row.enabled ? 1 : 0.58 }}>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{!readOnly ? <Switch size="small" checked={row.enabled} aria-label={`启用任务 ${index + 1}`} onChange={(enabled) => onUpdateRow(row.id, { enabled })} /> : null}
|
||||
<span className="tabular-nums" style={{ color: theme.node.muted }}>{index + 1}</span>
|
||||
</div>
|
||||
{referenceColumns.map((column, columnIndex) => <ReferenceThumbnail key={column.id} node={nodeById.get(row.inputNodeIds[columnIndex])} label={batchReferenceMentionToken(columnIndex)} theme={theme} />)}
|
||||
<div className="min-w-0">
|
||||
{referenceColumns.map((column, columnIndex) => (
|
||||
<div key={column.id} className="flex justify-center">
|
||||
<ReferenceThumbnail
|
||||
node={nodeById.get(row.inputNodeIds[columnIndex])}
|
||||
label={batchReferenceMentionToken(columnIndex)}
|
||||
theme={theme}
|
||||
readOnly={readOnly}
|
||||
rowId={row.id}
|
||||
columnIndex={columnIndex}
|
||||
isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex}
|
||||
onPickFile={() => pickReferenceFile(row.id, columnIndex)}
|
||||
onUploadFile={(file) => onUploadReference?.(row.id, columnIndex, file)}
|
||||
onPointerDown={(event) => startCellDrag(event, row.id, columnIndex)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 pr-3">
|
||||
<CanvasResourceMentionTextarea
|
||||
value={row.prompt}
|
||||
references={references}
|
||||
@@ -126,27 +264,26 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
||||
sendOnEnter={false}
|
||||
mentionMenuWidth={300}
|
||||
aria-label={`任务 ${index + 1} 提示词`}
|
||||
placeholder="描述生成目标,输入 @ 引用本行参考图"
|
||||
containerClassName="h-[76px]"
|
||||
placeholder={hasGlobalPrompt ? "已使用全局提示词,可在此填写行级覆盖" : "描述生成目标,输入 @ 引用本行参考图"}
|
||||
containerClassName="h-[108px]"
|
||||
className="thin-scrollbar h-full w-full overflow-y-auto rounded-lg border px-3 py-2 text-xs leading-5 outline-none transition-shadow focus-visible:ring-2"
|
||||
style={{ background: subtleSurface, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
style={{ background: inputSurface, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(prompt) => onUpdateRow(row.id, { prompt })}
|
||||
onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between px-1 text-[10px]" style={{ color: theme.node.faint }}>
|
||||
<span>输入 @ 插入参考图</span>
|
||||
{!readOnly ? <span>⌘/Ctrl + Enter 单行生成</span> : null}
|
||||
<div className="mt-1 truncate px-0.5 text-[10px]" style={{ color: theme.node.faint }}>
|
||||
{readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{completedRow ? <CachedResourceImage eager src={output?.metadata?.previewContent || output?.metadata?.content} storageKey={output?.metadata?.storageKey} alt="生成结果" className="size-12 rounded-lg object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium" title={status.label}>{status.label}</div>
|
||||
{!readOnly && status.retryable && batch && item ? <Button type="link" size="small" className="h-auto p-0 text-[11px]" icon={<RefreshCw className="size-3" />} onClick={() => onRetryItem(batch.id, item.id)}>重试</Button> : completedRow ? <span className="text-[10px]" style={{ color: theme.node.muted }}>已关联到画布</span> : null}
|
||||
</div>
|
||||
{status.loading ? <LoaderCircle className="size-4 shrink-0 animate-spin" style={{ color: theme.node.muted }} /> : null}
|
||||
<div className="flex justify-center">
|
||||
<ResultThumbnail
|
||||
output={output}
|
||||
status={status}
|
||||
theme={theme}
|
||||
onFocus={() => { if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }}
|
||||
/>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
@@ -176,7 +313,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div role="group" aria-label={ariaLabel} className="flex items-center rounded-lg p-0.5" style={{ background: theme.node.panel }}>
|
||||
<div role="group" aria-label={ariaLabel} className="flex shrink-0 items-center rounded-lg p-0.5" style={{ background: theme.node.panel }}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return <button key={option.value} type="button" aria-pressed={selected} disabled={disabled} className={`rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "min-w-7 px-1.5 py-1 text-[10px]" : "px-2.5 py-1.5 text-[11px]"}`} style={{ background: selected ? theme.accent.primary : "transparent", color: selected ? theme.accent.onPrimary : theme.node.muted }} onClick={() => onChange(option.value)}>{option.label}</button>;
|
||||
@@ -219,39 +356,108 @@ function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType<type
|
||||
});
|
||||
}
|
||||
|
||||
function ReferenceThumbnail({ node, label, theme }: { node?: CanvasNodeData; label: string; theme: CanvasTheme }) {
|
||||
if (!node || !hasNodeMedia(node)) return <EmptyThumbnail theme={theme} />;
|
||||
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);
|
||||
const fallback = <EmptyThumbnail theme={theme} />;
|
||||
return (
|
||||
<Tooltip title={`${label} · ${node.title || "图片"}`}>
|
||||
<div className="relative size-14 overflow-hidden rounded-lg border" style={{ borderColor: theme.node.stroke }}>
|
||||
<CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="size-14 object-cover" fallback={fallback} />
|
||||
<Tooltip title={filled ? `${label} · ${node.title || "图片"}` : `${label} · 点击上传或拖入图片`}>
|
||||
<button
|
||||
type="button"
|
||||
data-batch-reference-cell
|
||||
data-row-id={rowId}
|
||||
data-column-index={columnIndex}
|
||||
className="relative size-16 overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: filled ? theme.node.stroke : "transparent", opacity: isDraggingCell ? 0.55 : 1 }}
|
||||
disabled={readOnly}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!readOnly) onPickFile();
|
||||
}}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
|
||||
if (file && !readOnly) onUploadFile(file);
|
||||
}}
|
||||
>
|
||||
{filled ? <CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="size-16 object-cover" fallback={fallback} /> : fallback}
|
||||
<span className="absolute bottom-1 left-1 rounded px-1 py-0.5 text-[8px] font-medium text-white" style={{ background: "rgba(0,0,0,.58)" }}>{label}</span>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode {
|
||||
return <div className={`grid shrink-0 place-items-center rounded-lg border ${compact ? "size-12" : "size-14"}`} style={{ borderColor: theme.node.stroke, color: theme.node.placeholder, background: `color-mix(in srgb, ${theme.node.text} 3%, transparent)` }}><ImageIcon className={compact ? "size-4" : "size-5"} /></div>;
|
||||
function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNodeData; status: ReturnType<typeof rowStatus>; theme: CanvasTheme; onFocus: () => void }) {
|
||||
const filled = hasNodeMedia(output);
|
||||
const tone = statusColor(status.tone, theme.node.stroke);
|
||||
const title = filled ? `${status.label} · 点击定位到画布节点` : status.label;
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
disabled={!output}
|
||||
className="relative size-16 overflow-hidden rounded-lg border-2"
|
||||
style={{ borderColor: tone, cursor: output ? "pointer" : "default" }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (output) onFocus();
|
||||
}}
|
||||
>
|
||||
{filled && output ? <CachedResourceImage eager src={output.metadata?.previewContent || output.metadata?.content} storageKey={output.metadata?.storageKey} alt="生成结果" className="size-16 object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
|
||||
{status.loading ? <span className="absolute inset-0 grid place-items-center bg-black/35"><LoaderCircle className="size-4 animate-spin" style={{ color: tone }} /></span> : null}
|
||||
<span className="absolute right-1 top-1 size-2 rounded-full" style={{ background: tone }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function rowReady(row: CanvasBatchRow, operation: CanvasBatchOperation, nodeById: Map<string, CanvasNodeData>) {
|
||||
if (!row.enabled || !row.prompt.trim()) return false;
|
||||
if (operation === "try_on" && row.inputNodeIds.length < 2) return false;
|
||||
if (!row.inputNodeIds.length) return false;
|
||||
return row.inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id)));
|
||||
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 = compact ? "size-12" : "size-16";
|
||||
return (
|
||||
<div
|
||||
className={`grid shrink-0 place-items-center rounded-lg border border-dashed ${sizeClass}`}
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.placeholder, background: `color-mix(in srgb, ${theme.node.text} 3%, transparent)` }}
|
||||
>
|
||||
{compact ? <ImageIcon className="size-4" /> : (
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<Upload className="size-4" />
|
||||
<span className="text-[9px] leading-none">上传</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodeById: Map<string, CanvasNodeData>) {
|
||||
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);
|
||||
}
|
||||
|
||||
function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) {
|
||||
if (hasNodeMedia(output)) 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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" },
|
||||
[CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" },
|
||||
[CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" },
|
||||
[CanvasNodeType.BatchTable]: { width: 900, height: 520, title: "批量创作表" },
|
||||
[CanvasNodeType.BatchTable]: { width: 1280, height: 560, title: "批量创作表" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
|
||||
@@ -5,9 +5,18 @@ 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:";
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 112;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 38;
|
||||
/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 102;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 40;
|
||||
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 defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
return [
|
||||
@@ -20,10 +29,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -63,6 +68,60 @@ export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hi
|
||||
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;
|
||||
const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` }));
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns,
|
||||
rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })),
|
||||
};
|
||||
}
|
||||
|
||||
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 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 batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
|
||||
return table.globalPrompt?.trim() || row.prompt;
|
||||
}
|
||||
|
||||
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const indexById = new Map(columns.map((column, index) => [column.id, index]));
|
||||
|
||||
@@ -154,7 +154,7 @@ const BUILTIN_NODE_TRAITS = {
|
||||
showInCreateMenu: true,
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 720, height: 360 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
|
||||
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
|
||||
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
label: "调色",
|
||||
|
||||
@@ -68,6 +68,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
||||
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
|
||||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
|
||||
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
|
||||
@@ -742,6 +743,7 @@ function InfiniteCanvasPage() {
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
replaceNodeMedia,
|
||||
createFileNode,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadTimelineMedia,
|
||||
@@ -1886,7 +1888,7 @@ function InfiniteCanvasPage() {
|
||||
handleGenerateNode,
|
||||
});
|
||||
|
||||
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, patchTable: patchBatchTable, removeRow: removeBatchRow, updateRow: updateBatchRow } = useCanvasBatchTable({
|
||||
const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
@@ -1895,6 +1897,22 @@ function InfiniteCanvasPage() {
|
||||
enqueueGenerationBatch,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
setNodes((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((node) => {
|
||||
const promoted = promoteLegacyBatchTableSize(node);
|
||||
if (promoted !== node) changed = true;
|
||||
return promoted;
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
|
||||
syncRowsFromConnections(node.id, true);
|
||||
});
|
||||
}, [connections, projectLoaded, setNodes, syncRowsFromConnections]);
|
||||
|
||||
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
|
||||
useCanvasStoryboard({
|
||||
projectId,
|
||||
@@ -2105,6 +2123,26 @@ function InfiniteCanvasPage() {
|
||||
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
||||
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
||||
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
||||
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
|
||||
onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)}
|
||||
onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
|
||||
onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
|
||||
onUploadReference={(rowId, columnIndex, file) => {
|
||||
const row = contentNode.metadata?.batchTable?.rows.find((item) => item.id === rowId);
|
||||
const existingId = row?.inputNodeIds[columnIndex];
|
||||
if (existingId) {
|
||||
void replaceNodeMedia(existingId, file);
|
||||
return;
|
||||
}
|
||||
void createFileNode(file, { x: contentNode.position.x - 180, y: contentNode.position.y + columnIndex * 90 }).then((insertedId) => {
|
||||
if (!insertedId) return;
|
||||
const currentRow = nodesRef.current.find((item) => item.id === contentNode.id)?.metadata?.batchTable?.rows.find((item) => item.id === rowId);
|
||||
const ids = [...(currentRow?.inputNodeIds || [])];
|
||||
while (ids.length <= columnIndex) ids.push("");
|
||||
ids[columnIndex] = insertedId;
|
||||
updateBatchRow(contentNode.id, rowId, { inputNodeIds: ids });
|
||||
});
|
||||
}}
|
||||
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
|
||||
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
|
||||
/>
|
||||
@@ -2189,6 +2227,7 @@ function InfiniteCanvasPage() {
|
||||
createScriptVideoNodes,
|
||||
currentProject?.directorScenes,
|
||||
fillRowsFromConnections,
|
||||
focusCanvasImageNode,
|
||||
generateBatchRows,
|
||||
generateScriptImages,
|
||||
generateScriptRows,
|
||||
@@ -2203,6 +2242,7 @@ function InfiniteCanvasPage() {
|
||||
openDirectorWorkbench,
|
||||
openStoryInput,
|
||||
patchBatchTable,
|
||||
removeBatchReferenceColumn,
|
||||
removeBatchRow,
|
||||
removeScriptRow,
|
||||
retryFailedBatchItems,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { batchInputColumns, batchReferenceColumns, createBatchRowsFromColumns, createInheritedBatchRow } from "@/lib/canvas/canvas-batch-table";
|
||||
import { 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";
|
||||
@@ -55,23 +55,65 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const fillRowsFromConnections = useCallback((nodeId: string) => {
|
||||
const removeReferenceColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
const nextTable = removeLastBatchReferenceColumn(table);
|
||||
if (!nextTable) return message.info("至少保留 1 组参考图");
|
||||
const removed = columns.at(-1);
|
||||
patchTable(nodeId, nextTable);
|
||||
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;
|
||||
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)) return message.warning("请先把图片节点连接到批量创作表");
|
||||
if (!columns.some((column) => column.length)) {
|
||||
if (!silent) message.warning("请先把图片节点连接到批量创作表");
|
||||
return false;
|
||||
}
|
||||
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
|
||||
if (!rows.length) return message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
if (!rows.length) {
|
||||
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
return false;
|
||||
}
|
||||
if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true;
|
||||
patchTable(nodeId, { rows });
|
||||
const added = Math.max(0, rows.length - table.rows.length);
|
||||
message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
|
||||
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;
|
||||
@@ -84,9 +126,10 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
|
||||
const rows = table.rows.filter((row) => {
|
||||
if (!row.enabled || (requested && !requested.has(row.id)) || !row.prompt.trim()) return false;
|
||||
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
|
||||
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
const 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;
|
||||
// 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。
|
||||
@@ -112,10 +155,11 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
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: row.prompt.trim(),
|
||||
composerContent: row.prompt.trim(),
|
||||
prompt,
|
||||
composerContent: prompt,
|
||||
model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
|
||||
generationMode: "image" as const,
|
||||
generationType: "edit" as const,
|
||||
@@ -134,7 +178,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
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 }));
|
||||
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 });
|
||||
@@ -149,5 +193,5 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
|
||||
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
|
||||
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
|
||||
|
||||
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, patchTable, removeRow, updateRow };
|
||||
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ const BATCH_UPLOAD_COLUMNS = 3;
|
||||
const BATCH_UPLOAD_COLUMN_GAP = 380;
|
||||
const BATCH_UPLOAD_ROW_GAP = 300;
|
||||
|
||||
const CANVAS_BATCH_TABLE_SELECTOR = "[data-canvas-batch-table]";
|
||||
|
||||
function isBatchTableDragEvent(event: DragEvent<HTMLElement>) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
return Boolean(target?.closest(CANVAS_BATCH_TABLE_SELECTOR));
|
||||
}
|
||||
|
||||
export function useCanvasUpload({
|
||||
canvasId,
|
||||
domainProjectId,
|
||||
@@ -535,6 +542,12 @@ export function useCanvasUpload({
|
||||
}, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
|
||||
|
||||
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
@@ -575,6 +588,12 @@ export function useCanvasUpload({
|
||||
}, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
|
||||
|
||||
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(event)) return;
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current += 1;
|
||||
@@ -582,12 +601,24 @@ export function useCanvasUpload({
|
||||
}, []);
|
||||
|
||||
const handleFileDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleFileDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (isBatchTableDragEvent(event)) {
|
||||
event.preventDefault();
|
||||
fileDragDepthRef.current = 0;
|
||||
setFileDropActive(false);
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(event)) return;
|
||||
fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1);
|
||||
if (fileDragDepthRef.current === 0) setFileDropActive(false);
|
||||
@@ -721,6 +752,7 @@ export function useCanvasUpload({
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
replaceNodeMedia,
|
||||
createFileNode,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadStatus,
|
||||
|
||||
@@ -166,7 +166,7 @@ export type CanvasGenerationBatch = {
|
||||
export type CanvasBatchOperation = "try_on" | "creative";
|
||||
export type CanvasBatchRow = { id: string; enabled: boolean; inputNodeIds: string[]; prompt: string; outputNodeId?: string };
|
||||
export type CanvasBatchReferenceColumn = { id: string; label: string };
|
||||
export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] };
|
||||
export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; globalPrompt?: string; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] };
|
||||
|
||||
export type CanvasSkillSnapshot = {
|
||||
id: string;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
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";
|
||||
@@ -67,8 +75,44 @@ describe("batch creation table", () => {
|
||||
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: [] });
|
||||
});
|
||||
|
||||
test("promotes the previous 900-wide default without touching resized tables", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const legacy = { ...node, width: 900, height: 520 };
|
||||
const resized = { ...node, width: 1100, height: 480 };
|
||||
|
||||
expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 });
|
||||
expect(promoteLegacyBatchTableSize(resized)).toBe(resized);
|
||||
});
|
||||
test("keeps a single explicitly stored reference column", () => {
|
||||
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: [] } };
|
||||
|
||||
expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]);
|
||||
});
|
||||
|
||||
test("removes the last reference column down to one and trims row inputs", () => {
|
||||
const table = {
|
||||
operation: "creative" 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 = 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();
|
||||
});
|
||||
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
@@ -77,12 +121,52 @@ describe("batch creation table", () => {
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2"));
|
||||
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 + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
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", () => {
|
||||
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: "one" },
|
||||
{ id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" },
|
||||
],
|
||||
};
|
||||
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"]);
|
||||
});
|
||||
|
||||
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("行提示词");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user