feat(canvas): 多模态表格 - 合并 PR #549 并保留本地交互与校验
This commit is contained in:
111
web/src/lib/canvas/canvas-batch-storyboard.ts
Normal file
111
web/src/lib/canvas/canvas-batch-storyboard.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 "角色配音一次只能引用一个角色卡";
|
||||
|
||||
Reference in New Issue
Block a user