feat(canvas): 多模态表格 - 合并 PR #549 并保留本地交互与校验
This commit is contained in:
523
web/src/pages/canvas/list-mode-generator.ts
Normal file
523
web/src/pages/canvas/list-mode-generator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user