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

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

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { Button } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { WandSparkles, X } from "lucide-react";
import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { CanvasCameraControlPopover } from "./canvas-camera-control-popover";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "transparentBackground" | "count"> & { cameraControl?: CameraControlOptions };
type BatchGenerationSettingsDialogProps = {
open: boolean;
config: AiConfig;
rowCount: number;
concurrency: number;
onClose: () => void;
onConfirm: (settings: BatchGenerationSettings) => void;
};
export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) {
const theme = canvasThemes[useActiveTheme()];
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
const [cameraControl, setCameraControl] = useState<CameraControlOptions | undefined>(undefined);
useEffect(() => {
if (open) {
setGenerationConfig(config);
setCameraControl(undefined);
}
}, [config, open]);
const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => {
setGenerationConfig((current) => ({ ...current, [key]: value }));
};
const handleModelChange = (model: string) => {
setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }));
};
const imageModel = generationConfig.imageModel || generationConfig.model;
return (
<AppModal
open={open}
onCancel={onClose}
footer={null}
centered
destroyOnHidden
width={480}
title="批量生成设置"
>
<div className="flex flex-col gap-4 py-2">
<div className="rounded-lg bg-black/5 px-3 py-2 text-sm dark:bg-white/[0.04]">
共 <span className="font-semibold">{rowCount}</span> 个任务(每行 1 张) · 并发上限 <span className="font-semibold">{concurrency}</span>
<div className="mt-1 text-xs opacity-75">确认后将提交生成任务,可能消耗积分或产生外部模型费用。</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">生成模型</div>
<ModelPicker
config={generationConfig}
value={imageModel}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={handleModelChange}
/>
</div>
<div className="border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<ImageSettingsPanel
config={generationConfig}
onConfigChange={handleConfigChange}
theme={theme}
showTitle={false}
showCount={false}
quickCount={4}
maxCount={10}
className="w-full space-y-3"
/>
</div>
<div className="flex items-center justify-between border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<div>
<div className="text-sm font-medium">摄像机控制</div>
<div className="mt-1 text-xs opacity-60">为本批次统一应用镜头、焦段和光圈设置</div>
</div>
<CanvasCameraControlPopover cameraControl={cameraControl} onCameraControlChange={setCameraControl} theme={theme} compact />
</div>
<div className="mt-2 flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button
type="primary"
icon={<WandSparkles className="size-4" />}
onClick={() => onConfirm({
model: generationConfig.model,
imageModel: generationConfig.imageModel,
quality: generationConfig.quality,
size: generationConfig.size,
transparentBackground: generationConfig.transparentBackground,
count: "1",
cameraControl,
})}
>
开始生成 {rowCount} 个任务
</Button>
</div>
</div>
</AppModal>
);
}

View File

@@ -1,6 +1,6 @@
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 { Film, 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";
@@ -13,6 +13,8 @@ import {
batchReferenceColumns,
batchReferenceHandleId,
batchReferenceMentionToken,
batchTextColumns,
batchRowReady as rowReady,
} from "@/lib/canvas/canvas-batch-table";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -32,6 +34,7 @@ type Props = {
onUpdateRow: (rowId: string, patch: Partial<CanvasBatchRow>) => void;
onFillRows: () => void;
onGenerate: (rowIds?: string[]) => void;
onCreateStoryboard?: () => void;
onRetryItem: (batchId: string, itemId: string) => void;
onAddReferenceColumn: () => void;
onRemoveReferenceColumn?: () => void;
@@ -51,9 +54,10 @@ 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, onRemoveReferenceColumn, onReorderReferenceColumns, onMoveReferenceCell, onUploadReference, onFocusOutput, onConnectStart, onConnectDrop, readOnly = false }: Props) {
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onCreateStoryboard, 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 textColumns = batchTextColumns(table);
const globalPrompt = table.globalPrompt || "";
const hasGlobalPrompt = Boolean(globalPrompt.trim());
const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]);
@@ -61,7 +65,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
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 gridTemplateColumns = `64px repeat(${referenceColumns.length}, 88px) ${textColumns.length ? `repeat(${textColumns.length}, minmax(168px, 0.75fr)) ` : ""}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);
@@ -165,7 +169,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
<Minus className="size-3.5" />
</button>
</Tooltip>
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}>
<Tooltip title={referenceColumns.length >= MAX_BATCH_REFERENCE_COLUMNS ? `最多支持 ${MAX_BATCH_REFERENCE_COLUMNS} 组参考图` : `新增参考图 ${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>
@@ -181,6 +185,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
<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>
{table.contentKind === "storyboard" && onCreateStoryboard ? <Button size="small" icon={<Film className="size-3.5" />} onClick={onCreateStoryboard}>创建视频脚本</Button> : null}
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!unfinishedReadyCount} onClick={() => onGenerate()}>
生成未完成项{unfinishedReadyCount ? ` · ${unfinishedReadyCount}` : ""}
</Button>
@@ -219,6 +224,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
{column.label}
</span>
))}
{textColumns.map((column) => <span key={column.id} className="min-w-0 truncate px-1" title={column.label}>{column.label}</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>
@@ -256,6 +262,18 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
/>
</div>
))}
{textColumns.map((column, columnIndex) => (
<div key={column.id} className="min-w-0 px-2">
{table.aiGenerated ? <textarea
aria-label={`任务 ${index + 1} ${column.label}`}
value={row.cells?.[column.id] || ""}
readOnly={readOnly}
className="thin-scrollbar h-[108px] w-full resize-none rounded-lg border px-2 py-2 text-xs outline-none focus-visible:ring-2"
style={{ background: inputSurface, borderColor: theme.node.stroke, color: theme.node.text }}
onChange={(event) => onUpdateRow(row.id, { cells: { ...row.cells, [column.id]: event.target.value } })}
/> : <span>{nodeById.get(row.textNodeIds?.[columnIndex] || "")?.metadata?.content || ""}</span>}
</div>
))}
<div className="min-w-0 pr-3">
<CanvasResourceMentionTextarea
value={row.prompt}
@@ -438,14 +456,6 @@ function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compac
);
}
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);
}

View File

@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { ArrowLeftRight, ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ArrowLeftRight, ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, LayoutList, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -56,6 +56,7 @@ type CanvasNodePromptPanelProps = {
onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void;
workspaceMode?: CanvasWorkspaceMode;
onListGenerate?: (nodeId: string, prompt: string) => void;
};
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
@@ -74,7 +75,7 @@ const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)";
const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200;
const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420;
export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional", onListGenerate }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const themeName = useActiveTheme();
const theme = canvasThemes[themeName];
@@ -289,7 +290,8 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return false;
onGenerate(node.id, mode, text);
if (mode === "text" && node.metadata?.listMode && onListGenerate) onListGenerate(node.id, text);
else onGenerate(node.id, mode, text);
return true;
};
@@ -454,17 +456,35 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
compact={!expanded}
/>
{mode === "text" ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
<InputNumber
size="small"
min={1}
max={15}
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))}
onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
aria-label="文本生成份数"
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
/>
</Tooltip>
<>
<div className="flex h-7 items-center overflow-hidden rounded-md border" style={{ borderColor: theme.node.stroke }}>
<button type="button" aria-pressed={!node.metadata?.listMode} onClick={() => onConfigChange(node.id, { listMode: false })} className={`flex h-full items-center gap-1 px-2 text-[var(--fs-tiny)] transition-colors focus-visible:outline ${!node.metadata?.listMode ? "font-medium" : ""}`} style={!node.metadata?.listMode ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.muted }}>
<FileText className="size-3" />
文本
</button>
<button type="button" aria-pressed={Boolean(node.metadata?.listMode)} onClick={() => onConfigChange(node.id, { listMode: true })} className={`flex h-full items-center gap-1 px-2 text-[var(--fs-tiny)] transition-colors focus-visible:outline ${node.metadata?.listMode ? "font-medium" : ""}`} style={node.metadata?.listMode ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.muted }}>
<LayoutList className="size-3" />
列表
</button>
</div>
{!node.metadata?.listMode ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
<InputNumber
size="small"
min={1}
max={15}
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))}
onChange={(value) =>
onConfigChange(node.id, {
textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value) || 1)))),
})
}
aria-label="文本生成份数"
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
/>
</Tooltip>
) : <span className="text-[10px]" style={{ color: theme.node.muted }}>行数和列结构由模型判断</span>}
</>
) : mode === "image" ? (
// 图片模式下,显示相机配置与镜头配置
<>

View File

@@ -0,0 +1,111 @@
import { createStoryboardRow } from "@/lib/canvas/canvas-project-domain";
import type { CanvasBatchRow, CanvasBatchTableData, CanvasNodeData, StoryboardRow } from "@/types/canvas";
type StoryboardField =
| "shotNumber" | "durationSeconds" | "plotDescription" | "dialogue" | "narrativeIntent" | "viewerPOV"
| "performanceBlocking" | "shotSize" | "emotion" | "lightingAndAtmosphere" | "audioEffects" | "camera"
| "motion" | "timeBeats" | "imageGenerationPrompt" | "videoMotionPrompt" | "mustHave" | "optionalDetails"
| "continuityOut" | "negativePrompt";
const FIELD_ALIASES: Record<StoryboardField, string[]> = {
shotNumber: ["镜头", "镜头号", "镜号", "分镜", "序号"],
durationSeconds: ["时长", "秒数", "持续时间", "镜头时长"],
plotDescription: ["画面描述", "剧情", "画面", "镜头内容", "场景描述", "分镜描述"],
dialogue: ["台词", "对白", "旁白", "文案", "口播"],
narrativeIntent: ["叙事目的", "剧情目的", "表达目的"],
viewerPOV: ["视角", "观看视角", "观众视角"],
performanceBlocking: ["表演调度", "动作调度", "人物动作", "表演"],
shotSize: ["景别", "镜头景别", "景别设计"],
emotion: ["情绪", "情感"],
lightingAndAtmosphere: ["光影氛围", "灯光", "氛围", "光线"],
audioEffects: ["音效", "声音", "音乐", "音频"],
camera: ["镜头设计", "摄影机", "摄影", "机位", "相机"],
motion: ["运镜", "镜头运动", "运动方式", "镜头动作"],
timeBeats: ["时间节拍", "节拍", "时间点", "时间线"],
imageGenerationPrompt: ["首帧提示词", "图片提示词", "画面提示词", "生图提示词"],
videoMotionPrompt: ["视频提示词", "动态提示词", "视频生成提示词", "视频描述"],
mustHave: ["必须包含", "必备元素", "关键元素"],
optionalDetails: ["可选细节", "补充细节"],
continuityOut: ["衔接", "结尾状态", "镜头衔接", "连续性"],
negativePrompt: ["负面提示词", "不要出现", "禁用元素"],
};
function normalize(value: string) {
return value.trim().toLocaleLowerCase().replace(/[\s\-_::,,。.!!??()()【】\[\]]/g, "");
}
function cellValue(table: CanvasBatchTableData, row: CanvasBatchRow, field: StoryboardField) {
const cells = row.cells || {};
if (cells[`storyboard-${field}`] !== undefined) return cells[`storyboard-${field}`];
const columns = table.textColumns || [];
const aliases = [field, ...(FIELD_ALIASES[field] || [])].map(normalize);
const column = columns.find((item) => aliases.some((alias) => normalize(item.label).includes(alias) || alias.includes(normalize(item.label))));
if (column && cells[column.id] !== undefined) return cells[column.id] || "";
const candidate = Object.entries(cells).find(([key]) => aliases.some((alias) => normalize(key).includes(alias) || alias.includes(normalize(key))));
if (candidate) return candidate[1] || "";
return undefined;
}
function numberValue(value: string, fallback: number, max = 60) {
const match = value.match(/\d+(?:\.\d+)?/);
const parsed = match ? Number(match[0]) : fallback;
return Number.isFinite(parsed) ? Math.max(1, Math.min(max, parsed)) : fallback;
}
function arrayValue(value: string) {
return value.split(/[\n,,、;;|]/).map((item) => item.trim()).filter(Boolean);
}
/** 把 AI 多维表格行转换成视频脚本节点使用的稳定分镜结构。 */
export function storyboardRowsFromBatchTable(table: CanvasBatchTableData): StoryboardRow[] {
return table.rows.filter((row) => row.enabled !== false).map((row, index) => {
const base = table.storyboardRows?.find((item) => item.id === row.id);
const get = (field: StoryboardField) => cellValue(table, row, field) ?? String(base?.[field] || "");
const plotDescription = get("plotDescription");
const motion = get("motion");
const videoMotionPrompt = get("videoMotionPrompt") || [plotDescription, motion].filter(Boolean).join(";");
const imageGenerationPrompt = get("imageGenerationPrompt") || plotDescription;
return createStoryboardRow(index + 1, {
...base,
id: row.id,
shotNumber: Math.round(numberValue(get("shotNumber"), index + 1, Number.MAX_SAFE_INTEGER)),
durationSeconds: numberValue(get("durationSeconds"), 6),
plotDescription,
dialogue: get("dialogue"),
narrativeIntent: get("narrativeIntent"),
viewerPOV: get("viewerPOV"),
performanceBlocking: get("performanceBlocking"),
shotSize: get("shotSize"),
emotion: get("emotion"),
lightingAndAtmosphere: get("lightingAndAtmosphere"),
audioEffects: get("audioEffects"),
camera: get("camera"),
motion,
timeBeats: get("timeBeats"),
imageGenerationPrompt,
videoMotionPrompt,
sourceStartMs: base?.sourceStartMs,
sourceEndMs: base?.sourceEndMs,
keyframeTimeMs: base?.keyframeTimeMs,
mustHave: arrayValue(get("mustHave")),
optionalDetails: arrayValue(get("optionalDetails")),
continuityOut: get("continuityOut"),
negativePrompt: get("negativePrompt"),
});
});
}
export function storyboardKeyframeTime(row: StoryboardRow) {
const time = row.keyframeTimeMs ?? (row.sourceStartMs !== undefined && row.sourceEndMs !== undefined ? (row.sourceStartMs + row.sourceEndMs) / 2 : undefined);
return time !== undefined && Number.isFinite(time) && time >= 0 ? Math.round(time) : undefined;
}
/** 抽帧会排序、去重且可能部分失败,因此必须按时间而不是数组下标绑定。 */
export function bindStoryboardKeyframes(rows: StoryboardRow[], frames: CanvasNodeData[], sourceNodeId: string) {
const byTime = new Map(frames.filter((node) => node.metadata?.videoFrameSourceNodeId === sourceNodeId).map((node) => [node.metadata?.videoFrameTimeMs, node.id]));
return rows.map((row) => {
const time = storyboardKeyframeTime(row);
const imageNodeId = time === undefined ? undefined : byTime.get(time);
return imageNodeId ? { ...row, imageNodeId } : row;
});
}

View File

@@ -9,7 +9,7 @@ export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
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;
export const MAX_BATCH_REFERENCE_COLUMNS = 10;
const LEGACY_BATCH_TABLE_WIDTH = 900;
/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */
@@ -36,6 +36,16 @@ export function batchReferenceHandleId(columnId: string) {
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
}
export function batchTextColumns(table?: CanvasBatchTableData) {
return table?.textColumns || [];
}
export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
return batchTextColumns(node.metadata?.batchTable).map((column) =>
Array.from(new Set(connections.filter((connection) => connection.toNodeId === node.id && connection.toHandleId === `batch-text:${column.id}` && connection.relation !== "batch-output").map((connection) => connection.fromNodeId))),
);
}
export function batchReferenceMentionToken(index: number) {
return `@参考图${index + 1}`;
}
@@ -122,11 +132,35 @@ export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchR
return table.globalPrompt?.trim() || row.prompt;
}
export function batchRowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodes: Map<string, CanvasNodeData>) {
if (!row.enabled || !batchPromptForRow(table, row).trim()) return false;
const inputs = row.inputNodeIds.filter(Boolean);
if (inputs.length < (table.operation === "try_on" ? 2 : 1)) return false;
return inputs.every((id) => {
const node = nodes.get(id);
return node?.type === "image" && Boolean(node.metadata?.content || node.metadata?.storageKey);
});
}
export function batchGenerationRows(source: CanvasNodeData, nodes: CanvasNodeData[], requestedRowIds?: string[]) {
const table = source.metadata?.batchTable;
if (!table) return [];
const byId = new Map(nodes.map((node) => [node.id, node]));
const active = new Set((source.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 ? new Set(requestedRowIds) : null;
return table.rows.filter((row) => {
if ((requested && !requested.has(row.id)) || !batchRowReady(row, table, byId)) return false;
const output = byId.get(row.outputNodeId || "");
if (output && active.has(output.id)) return false;
return Boolean(requested) || !Boolean(output?.metadata?.content || output?.metadata?.storageKey);
});
}
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
const columns = batchReferenceColumns(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) => {
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output" && !connection.toHandleId?.startsWith("batch-text:")).forEach((connection) => {
const columnId = batchReferenceColumnId(connection.toHandleId);
const index = columnId ? indexById.get(columnId) : 0;
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
@@ -180,7 +214,7 @@ export function createBatchRowsFromColumns(operation: CanvasBatchOperation, colu
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, cells: previous.cells, textNodeIds: previous.textNodeIds } : {}),
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
inputNodeIds,
};

View File

@@ -95,7 +95,11 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
if (!input) return "";
if (capability === "text") {
return input.audioCount > 0 ? "文本模型不支持参考音频" : "";
const text = modelCapabilityConfigFor(config, model).text;
if (input.imageCount > (text?.references.maxImages ?? 0)) return `最多支持 ${(text?.references.maxImages ?? 0)} 张参考图`;
if (input.videoCount > (text?.references.maxVideos ?? 0)) return `最多支持 ${(text?.references.maxVideos ?? 0)} 个参考视频`;
if (input.audioCount > 0) return "文本模型不支持参考音频";
return "";
}
if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡";

View File

@@ -0,0 +1,523 @@
import { nanoid } from "nanoid";
import { message } from "antd";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow, type StoryboardRow } from "@/types/canvas";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
import { modelCompatibilityError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
import type { AiConfig } from "@/stores/use-config-store";
import type { ReferenceVideo } from "@/types/media";
import { createStoryboardRow, cinematicStoryboardColumns, storyboardRowsOutputContract } from "@/lib/canvas/canvas-project-domain";
import type { StoryboardColumn } from "@/types/canvas";
const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你产品图片和一个任务描述。
请严格根据用户要求的数量生成数据;如果用户说“生成5个卖点”,就必须返回5行,每行一个独立卖点。
输出严格为以下 JSON 格式(不要输出其他内容):
{
"columns": ["列名1", "列名2", "列名3"],
"rows": [
{"列名1": "内容", "列名2": "内容", "列名3": "内容"}
]
}
规则:
1. 第一列不需要输出(它是图片本身),从第二列开始定义分析维度
2. 列名根据任务需求自动决定(如:核心卖点、视觉细节、文案标题、搭配建议等)
3. 用户明确要求数量时,rows 数量必须严格等于目标数量;一行只表达一个独立任务或卖点
4. 用户没有明确数量时,多张图片默认每张图片一行
5. 每个单元格内容简洁有力,适合电商/社交媒体使用
6. 不要把多个编号卖点塞进一个单元格;不要把整段分析只放进“任务提示词”
7. 列数建议 3-6 列,根据任务复杂度决定
8. 只输出 JSON,不要有任何其他文字`;
const VIDEO_LIST_MODE_SYSTEM_PROMPT = `你是专业的视频内容分析与分镜脚本助手。用户会给你一个视频和任务要求。
请完整观看/理解视频内容,按时间顺序拆解关键情节、动作、台词、画面和镜头运动;不要只做概括。
如果用户要求拆解脚本或关键帧,请为每个镜头输出可直接用于视频节点生成的结构化分镜数据。
${storyboardRowsOutputContract("如果用户要求关键帧,请让每个镜头的 imageGenerationPrompt 能描述该镜头代表性首帧;durationSeconds 填写该镜头时长。")}
输出格式:
{
"title": "视频脚本拆解",
"rows": [
{
"shotNumber": 1,
"durationSeconds": 3,
"sourceStartSeconds": 0,
"sourceEndSeconds": 3,
"keyframeTimeSeconds": 1.5,
"plotDescription": "画面和动作",
"dialogue": "台词或旁白",
"shotSize": "景别",
"camera": "机位和镜头设计",
"motion": "运镜",
"videoMotionPrompt": "可直接用于视频生成的动态描述",
"imageGenerationPrompt": "可直接用于首帧生成的画面描述",
"audioEffects": "音乐/音效",
"continuityOut": "镜头结尾状态"
}
]
}
只输出 JSON,不要 Markdown、代码块或解释文字。`;
type ListGenerationResult = {
columns: string[];
rows: Record<string, string>[];
};
export function requestedListRowCount(prompt: string): number | undefined {
const match = prompt.match(/(?:生成|写|列出|提供|给我|帮我)?[^\n]{0,12}?(\d+|[零〇一二两三四五六七八九十百]+)\s*(?:个|条|项|行|款|种)(?:卖点|标题|文案|内容|任务|要点)?/i);
if (!match) return undefined;
const value = /^\d+$/.test(match[1]) ? Number(match[1]) : chineseNumber(match[1]);
return Number.isInteger(value) && value > 0 ? Math.min(value, 100) : undefined;
}
function chineseNumber(value: string) {
const digits: Record<string, number> = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
if (value === "十") return 10;
if (value.startsWith("十")) return 10 + (digits[value[1]] || 0);
if (value.length === 2 && value[1] === "十") return (digits[value[0]] || 0) * 10;
if (value.length === 3 && value[1] === "十") return (digits[value[0]] || 0) * 10 + (digits[value[2]] || 0);
if (value.includes("百")) return (digits[value[0]] || 0) * 100 + (value[2] ? (digits[value[2]] || 0) * 10 : 0) + (value[3] ? digits[value[3]] || 0 : 0);
return value.split("").reduce((total, digit) => total * 10 + (digits[digit] ?? 0), 0);
}
export function parseListModeJson(text: string): ListGenerationResult | null {
const candidates = [
text.trim(),
...Array.from(text.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/gi), (match) => match[1].trim()),
...balancedJsonObjects(text),
];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as { columns?: unknown; rows?: unknown };
const columns = normalizeListColumns(parsed.columns);
if (!columns.length || !Array.isArray(parsed.rows) || !parsed.rows.length) continue;
const rows = parsed.rows
.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object" && !Array.isArray(row))
.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, listCellText(value)])));
if (rows.length) return { columns, rows };
} catch {
// Try the next candidate. Models often wrap valid JSON in prose or a code block.
}
}
return null;
}
export function parseStoryboardJson(text: string): { title?: string; rows: StoryboardRow[] } | null {
const candidates = [
text.trim(),
...Array.from(text.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/gi), (match) => match[1].trim()),
...balancedJsonObjects(text),
];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as { title?: unknown; rows?: unknown };
if (!Array.isArray(parsed.rows) || !parsed.rows.length) continue;
const rows = parsed.rows
.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object" && !Array.isArray(row))
.map((row, index) => createStoryboardRow(index + 1, {
shotNumber: Number(row.shotNumber) || index + 1,
durationSeconds: Math.max(1, Math.min(60, Number(row.durationSeconds) || 6)),
sourceStartMs: sourceTimeMs(row.sourceStartSeconds),
sourceEndMs: sourceTimeMs(row.sourceEndSeconds),
keyframeTimeMs: sourceTimeMs(row.keyframeTimeSeconds),
plotDescription: listCellText(row.plotDescription),
dialogue: listCellText(row.dialogue),
narrativeIntent: listCellText(row.narrativeIntent),
viewerPOV: listCellText(row.viewerPOV),
performanceBlocking: listCellText(row.performanceBlocking),
shotSize: listCellText(row.shotSize),
emotion: listCellText(row.emotion),
lightingAndAtmosphere: listCellText(row.lightingAndAtmosphere),
audioEffects: listCellText(row.audioEffects),
camera: listCellText(row.camera),
motion: listCellText(row.motion),
timeBeats: listCellText(row.timeBeats),
imageGenerationPrompt: listCellText(row.imageGenerationPrompt) || listCellText(row.plotDescription),
videoMotionPrompt: listCellText(row.videoMotionPrompt) || [listCellText(row.plotDescription), listCellText(row.motion)].filter(Boolean).join(";"),
mustHave: Array.isArray(row.mustHave) ? row.mustHave.map(listCellText).filter(Boolean) : [],
optionalDetails: Array.isArray(row.optionalDetails) ? row.optionalDetails.map(listCellText).filter(Boolean) : [],
continuityOut: listCellText(row.continuityOut),
negativePrompt: listCellText(row.negativePrompt),
}));
if (rows.length) return { title: typeof parsed.title === "string" ? parsed.title : undefined, rows };
} catch {
// Continue with the next JSON candidate.
}
}
return null;
}
function sourceTimeMs(value: unknown) {
if ((typeof value !== "string" && typeof value !== "number") || String(value).trim() === "") return undefined;
const seconds = Number(value);
return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : undefined;
}
function isLikelyVideoUrl(value: string) {
try {
const url = new URL(value);
if (!/^https?:$/i.test(url.protocol)) return false;
return /\.(mp4|mov|webm|m4v|avi|mkv|m3u8)(?:$|[?#])/i.test(url.pathname + url.search) || /(?:video|vod|stream|play|media)/i.test(url.pathname + url.search);
} catch {
return false;
}
}
function videoUrlsFromPrompt(prompt: string) {
const wantsVideo = /视频|拆解|脚本|关键帧|video|storyboard/i.test(prompt);
return Array.from(prompt.matchAll(/https?:\/\/[^\s<>()"']+/gi), (match) => match[0].replace(/[,。!?;;]+$/, "")).filter((url) => wantsVideo || isLikelyVideoUrl(url));
}
function storyboardColumnLabel(column: StoryboardColumn) {
const labels: Partial<Record<StoryboardColumn, string>> = {
durationSeconds: "时长(秒)",
plotDescription: "画面描述",
dialogue: "台词/旁白",
narrativeIntent: "叙事目的",
viewerPOV: "观看视角",
performanceBlocking: "动作调度",
shotSize: "景别",
emotion: "情绪",
lightingAndAtmosphere: "光影氛围",
audioEffects: "音效/音乐",
camera: "镜头设计",
motion: "运镜",
timeBeats: "时间节拍",
imageGenerationPrompt: "首帧提示词",
videoMotionPrompt: "视频提示词",
continuityOut: "结尾状态",
negativePrompt: "负面提示词",
};
return labels[column] || column;
}
function storyboardCells(row: StoryboardRow) {
const values: Record<string, string> = {};
const fields: Array<keyof StoryboardRow> = ["durationSeconds", "plotDescription", "dialogue", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "camera", "motion", "timeBeats", "imageGenerationPrompt", "videoMotionPrompt", "continuityOut", "negativePrompt"];
fields.forEach((field) => { values[`storyboard-${field}`] = String(row[field] || ""); });
values[`storyboard-mustHave`] = row.mustHave.join("、");
values[`storyboard-optionalDetails`] = row.optionalDetails.join("、");
return values;
}
function balancedJsonObjects(text: string) {
const results: string[] = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
if (depth === 0) start = index;
depth += 1;
} else if (char === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start >= 0) {
results.push(text.slice(start, index + 1));
start = -1;
}
}
}
return results;
}
function normalizeListColumns(value: unknown) {
if (!Array.isArray(value)) return [];
const used = new Set<string>();
return value.flatMap((item) => {
const label = typeof item === "string" ? item.trim() : "";
if (!label || used.has(label)) return [];
used.add(label);
return [label];
});
}
function listCellText(value: unknown): string {
if (typeof value === "string") return value.trim();
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(listCellText).filter(Boolean).join("、");
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function normalizedCellKey(value: string) {
return value.trim().toLocaleLowerCase().replace(/[\s\-_::,,。.!!??()()【】\[\]]/g, "");
}
function rowCellValue(row: Record<string, string>, columnName: string, columnIndex: number) {
if (row[columnName]) return row[columnName];
const target = normalizedCellKey(columnName);
const matchedKey = Object.keys(row).find((key) => normalizedCellKey(key) === target || normalizedCellKey(key).includes(target) || target.includes(normalizedCellKey(key)));
if (matchedKey && row[matchedKey]) return row[matchedKey];
return Object.values(row)[columnIndex] || "";
}
function splitNumberedText(value: string) {
return value
.split(/\n+/)
.flatMap((line) => line.split(/(?=(?:^|\s)(?:\d{1,3}[.、))]|[一二三四五六七八九十百]+[、.))]))/))
.map((item) => item.trim().replace(/^(?:\d{1,3}[.、))]|[一二三四五六七八九十百]+[、.))])\s*/, ""))
.filter(Boolean);
}
function normalizeRequestedRows(parsed: ListGenerationResult, targetCount?: number) {
if (!targetCount || parsed.rows.length >= targetCount) return targetCount ? parsed.rows.slice(0, targetCount) : parsed.rows;
const firstColumn = parsed.columns[0];
const expanded = parsed.rows.flatMap((row) => {
const first = rowCellValue(row, firstColumn, 0);
const parts = splitNumberedText(first);
if (parts.length <= 1) return [row];
return parts.map((part) => ({ ...row, [firstColumn]: part }));
});
return expanded.slice(0, targetCount);
}
type HandleListGenerateOptions = {
sourceNodeId: string;
prompt: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
config: AiConfig;
projectId: string;
setNodes: (updater: (prev: CanvasNodeData[]) => CanvasNodeData[]) => void;
setConnections: (updater: (prev: CanvasConnection[]) => CanvasConnection[]) => void;
setRunningNodeId: (id: string | null) => void;
setDialogNodeId: (id: string | null) => void;
};
export async function handleListGenerate({
sourceNodeId,
prompt,
nodes,
connections,
config,
projectId,
setNodes,
setConnections,
setRunningNodeId,
setDialogNodeId,
}: HandleListGenerateOptions) {
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
if (!sourceNode) return;
// Find connected media nodes. 视频列表模式不再强制要求图片;可使用连接的视频节点,
// 或从提示词中识别出直接粘贴的视频地址。
const connectedImageIds = connections
.filter((c) => c.toNodeId === sourceNodeId && c.fromNodeId !== sourceNodeId)
.map((c) => c.fromNodeId);
const imageNodes = nodes.filter((n) => connectedImageIds.includes(n.id) && n.type === CanvasNodeType.Image && Boolean(n.metadata?.content || n.metadata?.storageKey));
const videoNodes = nodes.filter((n) => connectedImageIds.includes(n.id) && n.type === CanvasNodeType.Video && Boolean(n.metadata?.content || n.metadata?.storageKey));
const promptVideoUrls = videoNodes.length ? [] : videoUrlsFromPrompt(prompt);
const isVideoAnalysis = videoNodes.length > 0 || promptVideoUrls.length > 0;
if (imageNodes.length === 0 && !isVideoAnalysis) {
message.warning("请先连接图片或视频到当前文本节点,也可以直接粘贴可访问的视频地址");
return;
}
setRunningNodeId(sourceNodeId);
try {
const targetRowCount = requestedListRowCount(prompt);
// 列表模式必须沿用文本节点当前选中的模型。此前这里清空 node.metadata.model,
// 会退回全局 textModel;当全局模型是普通文本模型时,界面虽显示 Gemini,
// 实际请求却会发给不支持图片的旧模型。
const requirements: ModelRequirements = {
capability: "text",
input: { textCount: 1, imageCount: imageNodes.length, videoCount: videoNodes.length + promptVideoUrls.length, audioCount: 0, characterCount: 0 },
options: modelRequestOptions(config, "text"),
};
const preferredModel = resolveCanvasGenerationModel(config, sourceNode.metadata?.model, "text") || resolveCanvasGenerationModel(config, config.textModel, "text");
if (!preferredModel) throw new Error("当前没有可用的文本模型,请先在模型设置中选择模型");
const compatibleModel = preferredModel;
if (!compatibleModel || modelCompatibilityError(config, compatibleModel, requirements)) {
throw new Error("当前文本模型未配置所需的图片或视频输入能力,请在节点中选择能力匹配的模型;不会自动切换模型");
}
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: compatibleModel } };
const listConfig = { ...buildGenerationConfig(config, sourceNodeForConfig, "text", requirements), model: compatibleModel };
const referenceImages = imageNodes.map((node) => ({
id: node.id,
name: node.title || "image",
type: "image",
dataUrl: node.metadata?.content || "",
storageKey: node.metadata?.storageKey,
}));
const referenceVideos: ReferenceVideo[] = [
...videoNodes.map((node) => ({
id: node.id,
name: node.title || "video",
type: "video",
url: node.metadata?.content || "",
storageKey: node.metadata?.storageKey,
durationMs: node.metadata?.durationMs,
})),
...promptVideoUrls.map((url, index) => ({ id: `prompt-video-${index}`, name: `video-${index + 1}`, type: "video", url })),
];
const fullPrompt = `${isVideoAnalysis ? VIDEO_LIST_MODE_SYSTEM_PROMPT : LIST_MODE_SYSTEM_PROMPT}\n\n用户任务:${prompt}\n\n图片数量:${imageNodes.length} 张\n视频数量:${referenceVideos.length} 个\n目标行数:${targetRowCount || "未指定,按任务判断"}`;
const result = await runBackendGenerationTask({
projectId,
mode: "text",
prompt: fullPrompt,
config: listConfig,
referenceImages,
referenceVideos,
streamText: false,
});
const text = result.text || "";
if (isVideoAnalysis) {
const storyboard = parseStoryboardJson(text);
if (!storyboard) {
message.error("视频分析返回格式异常,请重试");
return;
}
const storyboardRows = storyboard.rows.map((row, index) => ({ ...row, shotNumber: index + 1 }));
const batchTable: CanvasBatchTableData = {
operation: "creative",
concurrency: 10,
aiGenerated: true,
contentKind: "storyboard",
storyboardRows,
storyboardTitle: storyboard.title || "视频脚本拆解",
storyboardSourceNodeIds: [...videoNodes.map((node) => node.id), ...imageNodes.map((node) => node.id)],
referenceColumns: [{ id: "ai-ref", label: "输入视频", type: "image" }],
textColumns: cinematicStoryboardColumns()
.filter((column) => !["shotNumber", "assets"].includes(column))
.map((column) => ({ id: `storyboard-${column}`, label: storyboardColumnLabel(column), type: "text" as const })),
rows: storyboardRows.map((row) => ({
id: row.id,
enabled: true,
inputNodeIds: videoNodes.length ? [videoNodes[0].id] : [],
prompt: row.videoMotionPrompt || row.plotDescription,
cells: storyboardCells(row),
})),
};
const sourcePos = sourceNode.position;
const batchNodeId = `batch-table-${nanoid()}`;
const batchNode: CanvasNodeData = {
id: batchNodeId,
type: CanvasNodeType.BatchTable,
title: storyboard.title || "视频分镜多维表格",
position: { x: sourcePos.x + 400, y: sourcePos.y },
width: 1280,
height: 560,
metadata: { batchTable, model: listConfig.model, status: "idle" },
};
const newConnections: CanvasConnection[] = [...videoNodes, ...imageNodes].map((inputNode) => ({
id: `conn-${nanoid()}`,
fromNodeId: inputNode.id,
fromHandleId: "output",
toNodeId: batchNodeId,
toHandleId: "batch-reference:ai-ref",
relation: "batch-input",
}));
setNodes((prev) => [...prev, batchNode]);
setConnections((prev) => [...prev, ...newConnections]);
setDialogNodeId(batchNodeId);
message.success(`已生成视频分镜表:${storyboardRows.length} 个镜头`);
return;
}
const parsed = parseListModeJson(text);
if (!parsed) {
message.error("AI 返回格式异常,请重试");
return;
}
const rowsForTable = normalizeRequestedRows(parsed, targetRowCount);
if (targetRowCount && rowsForTable.length !== targetRowCount) {
throw new Error(`模型只返回了 ${rowsForTable.length} 行,未满足你要求的 ${targetRowCount} 行,请重试`);
}
// Build batch table data
const textColumns = parsed.columns.map((name, i) => ({
id: `ai-col-${i}`,
label: name,
type: "text" as const,
}));
const referenceColumn = { id: "ai-ref", label: "输入", type: "image" as const };
const rows: CanvasBatchRow[] = rowsForTable.map((row, rowIndex) => {
const imageNode = imageNodes[rowIndex % imageNodes.length];
const cells: Record<string, string> = {};
parsed.columns.forEach((colName, colIndex) => {
cells[`ai-col-${colIndex}`] = rowCellValue(row, colName, colIndex);
});
// Build prompt from all text cells
const cellPrompts = parsed.columns.map((colName, colIndex) => {
const val = cells[`ai-col-${colIndex}`];
return val ? `${colName}:${val}` : "";
}).filter(Boolean);
return {
id: `batch-row-${nanoid()}`,
enabled: true,
inputNodeIds: imageNode ? [imageNode.id] : [],
prompt: cellPrompts.join("\n"),
cells,
};
});
const batchTable: CanvasBatchTableData = {
operation: "creative",
concurrency: 10,
aiGenerated: true,
referenceColumns: [referenceColumn],
textColumns,
rows,
};
// Create the batch table node
const sourcePos = sourceNode.position;
const batchNodeId = `batch-table-${nanoid()}`;
const batchNode: CanvasNodeData = {
id: batchNodeId,
type: CanvasNodeType.BatchTable,
title: "AI 多维表格",
position: { x: sourcePos.x + 400, y: sourcePos.y },
width: 1280,
height: 560,
metadata: {
batchTable,
model: listConfig.model,
status: "idle",
},
};
// Create connections from each image node to the batch table's reference handle
const newConnections: CanvasConnection[] = imageNodes.map((imgNode) => ({
id: `conn-${nanoid()}`,
fromNodeId: imgNode.id,
fromHandleId: "output",
toNodeId: batchNodeId,
toHandleId: "batch-reference:ai-ref",
relation: "batch-input",
}));
setNodes((prev) => [...prev, batchNode]);
setConnections((prev) => [...prev, ...newConnections]);
setDialogNodeId(batchNodeId);
message.success(`已生成多维表格:${parsed.columns.length} 列 × ${rows.length} 行`);
} catch (err) {
message.error(err instanceof Error ? err.message : "列表生成失败");
} finally {
setRunningNodeId(null);
}
}

View File

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

View File

@@ -1,14 +1,15 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
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 { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
import { MAX_BATCH_REFERENCE_COLUMNS, batchGenerationRows, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextInputColumns, createInheritedBatchRow, 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 { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
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[] };
@@ -19,11 +20,21 @@ type Options = {
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
};
type PendingBatchGen = {
nodeId: string;
rows: CanvasBatchRow[];
concurrency: number;
tableSnapshot: string;
requestedRowIds?: string[];
};
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
const { message, modal } = App.useApp();
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<CanvasBatchTableData>) => {
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]);
@@ -50,7 +61,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
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 组参考图");
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]);
@@ -61,18 +72,29 @@ 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 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;
// AI 列表模式已经根据用户要求生成了独立行;参考图连线只负责
// 提供素材,不能在保存/连线刷新时把 N 行重置成“每张图一行”。
if (table.aiGenerated) 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);
@@ -83,16 +105,25 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
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.some((column) => column.length) ? textColumns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[index];
return input ? [input] : [];
}) : row.textNodeIds,
}));
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} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
}
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]);
@@ -114,49 +145,23 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows });
}, [nodesRef, patchTable]);
const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => {
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 imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
navigateToSettings({ continueCreation: true });
const selectableRowIds = new Set(batchGenerationRows(sourceNode, nodesRef.current, pending.requestedRowIds).map((row) => row.id));
if (JSON.stringify(table) !== pending.tableSnapshot || !rows.every((row) => selectableRowIds.has(row.id))) {
message.warning("表格、素材或任务状态已变化,请重新打开生成设置后提交");
return;
}
const mergedConfig = { ...effectiveConfig, ...settings };
if (!isAiConfigReady(mergedConfig, mergedConfig.imageModel || mergedConfig.model)) {
message.error("所选图片模型尚未配置,未提交生成任务");
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<boolean>((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];
@@ -169,11 +174,14 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
const metadata = {
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
prompt,
composerContent: prompt,
model: generationConfig.model,
size: generationConfig.size,
quality: generationConfig.quality,
transparentBackground: generationConfig.transparentBackground,
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: 1,
generationMode: "image" as const,
generationType: "edit" as const,
@@ -184,6 +192,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
batchRowId: row.id,
batchOperation: table.operation,
batchInputNodeIds: row.inputNodeIds,
cameraControl: settings.cameraControl,
};
const output = existingIndex >= 0
? { ...nextNodes[existingIndex], metadata }
@@ -204,8 +213,55 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
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]);
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
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 rows = batchGenerationRows(sourceNode, nodesRef.current, requestedRowIds);
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency, tableSnapshot: JSON.stringify(table), requestedRowIds } });
}, [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,
};
}

View File

@@ -263,9 +263,9 @@ export function useCanvasMediaTools({
setFrameDialogNodeId(null);
}, []);
const extractVideoFrames = useCallback(async (node: CanvasNodeData, params: CanvasVideoFrameParams) => {
const extractVideoFrames = useCallback(async (node: CanvasNodeData, params: CanvasVideoFrameParams): Promise<CanvasNodeData[]> => {
const content = node.metadata?.content;
if (!content || extractingVideoFramesNodeIdRef.current || !params.timesMs.length) return;
if (!content || extractingVideoFramesNodeIdRef.current || !params.timesMs.length) return [];
const progress = startUploadStatus("提取视频画面", "读取视频资源", params.timesMs.length + 2);
extractingVideoFramesNodeIdRef.current = node.id;
setExtractingVideoFramesNodeId(node.id);
@@ -302,10 +302,12 @@ export function useCanvasMediaTools({
const failedCount = captured.failures.length + uploadFailures.length;
progress.done(failedCount ? `已提取 ${frameNodes.length} 帧,${failedCount} 帧失败` : `已提取 ${frameNodes.length} 帧并创建图片节点`);
if (failedCount) message.warning(`${failedCount} 个时间点提取失败,其余画面已创建`);
return frameNodes;
} catch (error) {
const details = error instanceof Error ? error.message : "视频画面提取失败";
progress.fail(details);
message.error(details);
return [];
} finally {
extractingVideoFramesNodeIdRef.current = null;
setExtractingVideoFramesNodeId(null);

View File

@@ -129,6 +129,10 @@ export type StoryboardRow = {
continuityOut: string;
negativePrompt: string;
assetBindings: StoryboardAssetBinding[];
/** 原视频拆解时的时间范围,用于自动抽取关键帧。 */
sourceStartMs?: number;
sourceEndMs?: number;
keyframeTimeMs?: number;
imageNodeId?: string;
videoNodeId?: string;
status?: CanvasNodeStatus;
@@ -173,19 +177,31 @@ export type CanvasBatchRow = {
textNodeIds?: string[];
prompt: string;
outputNodeId?: string;
/** AI-generated cell content keyed by column id (for aiGenerated tables). */
cells?: Record<string, string>;
};
export type CanvasBatchColumnType = "image" | "text";
export type CanvasBatchTableContentKind = "content" | "storyboard";
export type CanvasBatchReferenceColumn = {
id: string;
label: string;
type?: CanvasBatchColumnType;
};
export type CanvasBatchTableData = {
operation: CanvasBatchOperation;
concurrency: number;
/** AI 输出的业务结构;分镜表会保留标准 StoryboardRow,避免依赖列名猜测。 */
contentKind?: CanvasBatchTableContentKind;
storyboardRows?: StoryboardRow[];
storyboardTitle?: string;
storyboardSourceNodeIds?: string[];
/** Optional prompt override applied to every batch row while non-empty. */
globalPrompt?: string;
referenceColumns?: CanvasBatchReferenceColumn[];
textColumns?: CanvasBatchReferenceColumn[];
rows: CanvasBatchRow[];
/** When true, columns are AI-generated with dynamic headers. */
aiGenerated?: boolean;
};
export type CanvasSkillSnapshot = {
@@ -238,6 +254,8 @@ export type CanvasNodeMetadata = {
richText?: Record<string, unknown>;
composerContent?: string;
prompt?: string;
/** 文本节点是否处于列表模式;用于触发多模态分析并创建多维表格。 */
listMode?: boolean;
promptTemplateOperation?: string;
promptTemplateVariables?: Record<string, string>;
status?: CanvasNodeStatus;
@@ -491,7 +509,7 @@ export type CanvasConnection = {
toHandleId?: string;
fromAnchorRatio?: number;
toAnchorRatio?: number;
relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output";
relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output" | "batch-input";
storyboardRowId?: string;
};

View File

@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { bindStoryboardKeyframes, storyboardRowsFromBatchTable } from "@/lib/canvas/canvas-batch-storyboard";
import { createCanvasNode, createStoryboardRow } from "@/lib/canvas/canvas-project-domain";
import { CanvasNodeType, type CanvasBatchTableData } from "@/types/canvas";
test("storyboard conversion follows row identity and retains edits and asset bindings", () => {
const table: CanvasBatchTableData = {
operation: "creative", concurrency: 1,
textColumns: [{ id: "unrelated", label: "无关列" }],
rows: [
{ id: "second", enabled: true, prompt: "", inputNodeIds: [], cells: { "storyboard-shotNumber": "72", "storyboard-durationSeconds": "2.5", "storyboard-dialogue": "" } },
{ id: "new", enabled: true, prompt: "", inputNodeIds: [], cells: { unrelated: "不要猜成画面" } },
{ id: "first", enabled: false, prompt: "", inputNodeIds: [] },
],
storyboardRows: [
createStoryboardRow(1, { id: "first", plotDescription: "deleted" }),
createStoryboardRow(2, { id: "second", plotDescription: "second shot", dialogue: "old", keyframeTimeMs: 2000, imageNodeId: "existing-image" }),
],
};
const rows = storyboardRowsFromBatchTable(table);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ id: "second", shotNumber: 72, durationSeconds: 2.5, dialogue: "", plotDescription: "second shot", keyframeTimeMs: 2000, imageNodeId: "existing-image" });
expect(rows[1].plotDescription).toBe("");
});
test("keyframes bind by source and timestamp despite sorting, duplicates or partial extraction", () => {
const rows = [3000, 1000, 3000, 2000].map((time, index) => createStoryboardRow(index + 1, { keyframeTimeMs: time }));
const frame = (source: string, time: number) => createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { videoFrameSourceNodeId: source, videoFrameTimeMs: time });
const first = frame("video", 1000);
const third = frame("video", 3000);
const result = bindStoryboardKeyframes(rows, [first, third, frame("other", 2000)], "video");
expect(result.map((row) => row.imageNodeId)).toEqual([third.id, first.id, third.id, rows[3].imageNodeId]);
expect(result[3]).toBe(rows[3]);
expect(bindStoryboardKeyframes([createStoryboardRow(1, { sourceStartMs: 500, sourceEndMs: 1500 })], [first], "video")[0].imageNodeId).toBe(first.id);
});

View File

@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { canvasThemes } from "@/lib/canvas-theme";
import { CanvasNodeType } from "@/types/canvas";
const noop = () => {};
for (const mode of ["light", "dark"] as const) {
test(`merged batch table keeps local controls and dynamic cells in ${mode} mode`, () => {
const output = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { content: "data:image/png;base64,AA==" });
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 0, y: 0 }, {
batchTable: {
operation: "creative", concurrency: 1, aiGenerated: true, contentKind: "storyboard",
textColumns: [{ id: "detail", label: "画面细节" }],
referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }],
rows: [{ id: "row", enabled: true, inputNodeIds: [], prompt: "@参考图1 保留提示", cells: { detail: "已编辑内容" }, outputNodeId: output.id }],
},
});
const render = (readOnly = false) => renderToStaticMarkup(<CanvasBatchTableNodeContent
node={node} nodes={[node, output]} connections={[]} theme={canvasThemes[mode]} readOnly={readOnly}
onPatchTable={noop} onAddRow={noop} onRemoveRow={noop} onUpdateRow={noop} onFillRows={noop}
onGenerate={noop} onCreateStoryboard={noop} onRetryItem={noop} onAddReferenceColumn={noop}
onRemoveReferenceColumn={noop} onFocusOutput={noop} onConnectStart={noop}
/>);
const editable = render();
expect(editable).toContain('aria-label="减少一组参考图"');
expect(editable).toContain('aria-label="任务 1 提示词"');
expect(editable).toContain("点击定位到画布节点");
expect(editable).toContain("画面细节");
expect(editable).toContain("已编辑内容");
expect(editable).toContain("创建视频脚本");
const readOnly = render(true);
expect(readOnly).not.toContain('aria-label="减少一组参考图"');
for (const textarea of readOnly.match(/<textarea\b[^>]*>/g) || []) expect(textarea).toContain('readOnly=""');
expect(readOnly).not.toContain("创建视频脚本");
expect(readOnly).toContain("已编辑内容");
});
}

View File

@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import {
batchGenerationRows,
batchRowReady,
batchPromptForRow,
batchReferenceColumns,
batchReferenceHandleAtY,
@@ -21,6 +23,32 @@ import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { CanvasNodeType } from "@/types/canvas";
describe("batch creation table", () => {
test("bulk skips persisted results while explicit regeneration remains available", () => {
const input = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "input" });
const output = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "output" });
const row = { id: "row", enabled: true, inputNodeIds: [input.id, ""], prompt: "create", outputNodeId: output.id };
const table = { operation: "creative" as const, concurrency: 1, rows: [row] };
const source = createCanvasNode(CanvasNodeType.BatchTable, { x: 0, y: 0 }, { batchTable: table });
const nodes = [source, input, output];
expect(batchGenerationRows(source, nodes)).toEqual([]);
expect(batchGenerationRows(source, nodes, [row.id])).toEqual([row]);
expect(batchGenerationRows(source, nodes, [])).toEqual([]);
expect(batchGenerationRows(source, [source, output], [row.id])).toEqual([]);
expect(batchRowReady({ ...row, enabled: false }, table, new Map(nodes.map((node) => [node.id, node])))).toBe(false);
expect(batchGenerationRows(source, nodes.map((node) => node.id === input.id ? { ...node, type: CanvasNodeType.Video } : node), [row.id])).toEqual([]);
source.metadata!.generationBatches = [{ id: "batch", projectId: "project", sourceNodeId: source.id, mode: "batch_image", status: "running", createdAt: "", updatedAt: "", items: [{ id: "item", rowId: row.id, nodeId: output.id, retryCount: 0, status: "running" }] }];
expect(batchGenerationRows(source, nodes, [row.id])).toEqual([]);
});
test("sync preserves dynamic cells, text references and distinct manual row identities", () => {
const first = { id: "first", enabled: true, inputNodeIds: ["image", "old"], prompt: "first", cells: { detail: "edited" }, textNodeIds: ["text"] };
const second = { ...first, id: "second", prompt: "manual" };
const rows = createBatchRowsFromColumns("creative", [["image"], ["new"]], [first, second]);
expect(rows.map((row) => row.id)).toEqual(["first", "second"]);
expect(rows[0].cells).toEqual(first.cells);
expect(rows[0].textNodeIds).toEqual(first.textNodeIds);
expect(rows[1]).toEqual(second);
});
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"]);

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { parseListModeJson, parseStoryboardJson, requestedListRowCount } from "@/pages/canvas/list-mode-generator";
test("extracts the requested row count from natural language", () => {
expect(requestedListRowCount("帮我生成5个卖点")).toBe(5);
expect(requestedListRowCount("请写十条小红书标题")).toBe(10);
expect(requestedListRowCount("分析这张图")).toBeUndefined();
});
describe("list mode response parsing", () => {
test("rejects invalid rows instead of creating an empty success table", () => {
for (const rows of [[], [null, 1, "bad", []]]) {
expect(parseListModeJson(JSON.stringify({ columns: ["detail"], rows }))).toBeNull();
expect(parseStoryboardJson(JSON.stringify({ rows }))).toBeNull();
}
});
test("does not turn missing or invalid timestamps into a first-frame request", () => {
for (const time of [null, "", " ", false, -1, "invalid"]) {
const parsed = parseStoryboardJson(JSON.stringify({ rows: [{ plotDescription: "shot", keyframeTimeSeconds: time }] }));
expect(parsed?.rows[0].keyframeTimeMs).toBeUndefined();
}
expect(parseStoryboardJson('{"rows":[{"keyframeTimeSeconds":0}]}')?.rows[0].keyframeTimeMs).toBe(0);
});
test("parses plain JSON", () => {
expect(parseListModeJson('{"columns":["卖点"],"rows":[{"卖点":"轻便"}]}')).toEqual({
columns: ["卖点"],
rows: [{ 卖点: "轻便" }],
});
});
test("parses markdown JSON with surrounding explanation", () => {
expect(parseListModeJson('结果如下:\n```json\n{"columns":["标题"],"rows":[{"标题":"新品"}]}\n```\n')).toEqual({
columns: ["标题"],
rows: [{ 标题: "新品" }],
});
});
test("normalizes duplicate columns and non-string cells", () => {
expect(parseListModeJson('{"columns":["卖点","卖点","细节"],"rows":[{"卖点":["轻","薄"],"细节":123}]}')).toEqual({
columns: ["卖点", "细节"],
rows: [{ 卖点: "轻、薄", 细节: "123" }],
});
});
test("rejects malformed responses", () => {
expect(parseListModeJson("模型暂时无法分析")).toBeNull();
expect(parseListModeJson('{"columns":[],"rows":[]}')).toBeNull();
});
test("parses a storyboard response and derives generation prompts", () => {
const result = parseStoryboardJson('```json\n{"title":"拆解","rows":[{"durationSeconds":3,"plotDescription":"产品推近","motion":"缓慢推进","keyframeTimeSeconds":1.5}]}\n```');
expect(result?.title).toBe("拆解");
expect(result?.rows[0]).toMatchObject({ durationSeconds: 3, plotDescription: "产品推近", motion: "缓慢推进", videoMotionPrompt: "产品推近;缓慢推进", keyframeTimeMs: 1500 });
});
});

View File

@@ -4,6 +4,14 @@ import test from "node:test";
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
test("text multimodal capability is not guessed from a model name", () => {
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {
const text = defaultModelCapabilityConfig(undefined, model).text!;
assert.equal(text.references.maxImages, 0);
assert.equal(text.references.maxVideos, 0);
}
});
test("switching to MiniMax H3 replaces an unsupported 720p value with 768P", () => {
const profile = defaultModelCapabilityConfig("minimax-video", "MiniMax-H3").video!;