feat(canvas): support multimodal video storyboard tables
This commit is contained in:
@@ -112,6 +112,14 @@ func TestMatchCapabilityRoutesTextImagesOnlyToDeclaredProfiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchCapabilityRoutesUndeclaredVideoToTextMultimodalProfile(t *testing.T) {
|
||||||
|
profile := CapabilitySpec{Version: 1, Capability: "text"}
|
||||||
|
intent := ModelRequestIntent{Capability: "text", Inputs: map[string]int{"video": 1}}
|
||||||
|
if match := MatchCapability(profile, intent); !match.Matched {
|
||||||
|
t.Fatalf("multimodal text profile should accept legacy undeclared video input: %#v", match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchCapabilityAcceptsWildcardOptionValues(t *testing.T) {
|
func TestMatchCapabilityAcceptsWildcardOptionValues(t *testing.T) {
|
||||||
spec := CapabilitySpec{
|
spec := CapabilitySpec{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
|||||||
@@ -274,9 +274,12 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM
|
|||||||
}
|
}
|
||||||
constraint, declared := spec.Inputs[inputType]
|
constraint, declared := spec.Inputs[inputType]
|
||||||
if !declared {
|
if !declared {
|
||||||
// Multimodal text models may accept reference images even when the
|
// Multimodal text models may accept reference images or videos even
|
||||||
// provider catalog does not explicitly declare the image input.
|
// when the provider catalog does not explicitly declare the input.
|
||||||
if count > 0 && !(normalizeCapability(spec.Capability) == "text" && inputType == "image") {
|
// The concrete model capability still controls the frontend limits;
|
||||||
|
// this fallback keeps older catalog entries routable for known
|
||||||
|
// multimodal text endpoints.
|
||||||
|
if count > 0 && !(normalizeCapability(spec.Capability) == "text" && (inputType == "image" || inputType == "video")) {
|
||||||
reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入")
|
reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入")
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||||
import { Button, Input, Select, Switch, Tooltip } from "antd";
|
import { Button, Input, Select, Switch, Tooltip } from "antd";
|
||||||
import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react";
|
import { Film, GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react";
|
||||||
|
|
||||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||||
import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table";
|
import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table";
|
||||||
@@ -19,6 +19,7 @@ type Props = {
|
|||||||
onUpdateRow: (rowId: string, patch: Partial<CanvasBatchRow>) => void;
|
onUpdateRow: (rowId: string, patch: Partial<CanvasBatchRow>) => void;
|
||||||
onFillRows: () => void;
|
onFillRows: () => void;
|
||||||
onGenerate: (rowIds?: string[]) => void;
|
onGenerate: (rowIds?: string[]) => void;
|
||||||
|
onCreateStoryboard?: () => void;
|
||||||
onRetryItem: (batchId: string, itemId: string) => void;
|
onRetryItem: (batchId: string, itemId: string) => void;
|
||||||
onAddReferenceColumn: () => void;
|
onAddReferenceColumn: () => void;
|
||||||
onRemoveReferenceColumn?: () => void;
|
onRemoveReferenceColumn?: () => void;
|
||||||
@@ -41,7 +42,7 @@ const OPERATION_OPTIONS = [
|
|||||||
|
|
||||||
type ReferenceCell = { rowId: string; columnIndex: number };
|
type ReferenceCell = { rowId: string; columnIndex: number };
|
||||||
|
|
||||||
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn: _onRemoveReferenceColumn, onFocusOutput: _onFocusOutput, onAddTextColumn = () => {}, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference = () => {}, onUploadReference, onRemoveReference = () => {}, onConnectStart, onConnectDrop, readOnly = false }: Props) {
|
export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onCreateStoryboard, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn: _onRemoveReferenceColumn, onFocusOutput: _onFocusOutput, onAddTextColumn = () => {}, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference = () => {}, onUploadReference, onRemoveReference = () => {}, onConnectStart, onConnectDrop, readOnly = false }: Props) {
|
||||||
const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] };
|
const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] };
|
||||||
const referenceColumns = batchReferenceColumns(table);
|
const referenceColumns = batchReferenceColumns(table);
|
||||||
const textColumns = batchTextColumns(table);
|
const textColumns = batchTextColumns(table);
|
||||||
@@ -49,9 +50,10 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
|||||||
const hasGlobalPrompt = Boolean(globalPrompt.trim());
|
const hasGlobalPrompt = Boolean(globalPrompt.trim());
|
||||||
const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]);
|
const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]);
|
||||||
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
|
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
|
||||||
const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]);
|
const connectedMediaCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]);
|
||||||
const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length;
|
const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length;
|
||||||
const isAiTable = Boolean(table.aiGenerated);
|
const isAiTable = Boolean(table.aiGenerated);
|
||||||
|
const isStoryboardTable = table.contentKind === "storyboard" && Boolean(table.storyboardRows?.length);
|
||||||
const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length);
|
const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length);
|
||||||
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
|
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
|
||||||
const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null);
|
const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null);
|
||||||
@@ -244,7 +246,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
|||||||
aria-label="并发数"
|
aria-label="并发数"
|
||||||
/>
|
/>
|
||||||
<span className="opacity-55">
|
<span className="opacity-55">
|
||||||
{referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length}
|
{referenceColumns.length} 组参考 · 已连 {connectedMediaCount} 个素材 · 完成 {completed}/{table.rows.length}
|
||||||
</span>
|
</span>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<div className="flex min-w-[280px] max-w-[min(42vw,520px)] flex-1 items-center gap-2">
|
<div className="flex min-w-[280px] max-w-[min(42vw,520px)] flex-1 items-center gap-2">
|
||||||
@@ -268,6 +270,11 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
|||||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onAddRow}>
|
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onAddRow}>
|
||||||
加一行
|
加一行
|
||||||
</Button>
|
</Button>
|
||||||
|
{isStoryboardTable && 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={!table.rows.length} onClick={() => onGenerate()}>
|
<Button size="small" type="primary" icon={<Play className="size-3.5" />} disabled={!table.rows.length} onClick={() => onGenerate()}>
|
||||||
生成未完成项
|
生成未完成项
|
||||||
</Button>
|
</Button>
|
||||||
@@ -590,7 +597,7 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI
|
|||||||
onLostPointerCapture={onLostPointerCapture}
|
onLostPointerCapture={onLostPointerCapture}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<CachedResourceImage draggable={false} eager src={node.metadata.previewContent || node.metadata.content} storageKey={node.metadata.storageKey} alt={node.title || "参考图"} className="size-16 select-none object-cover" fallback={fallback} />
|
<CachedResourceImage draggable={false} eager src={node.type === "video" ? node.metadata.videoPreview?.content || node.metadata.videoPreview?.storageKey || "" : node.metadata.previewContent || node.metadata.content} storageKey={node.type === "video" ? node.metadata.videoPreview?.storageKey || node.metadata.storageKey : node.metadata.storageKey} alt={node.title || (node.type === "video" ? "参考视频" : "参考图")} className="size-16 select-none object-cover" fallback={fallback} />
|
||||||
{!readOnly ? <span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/55 px-1 py-1 text-[9px] text-white opacity-0 transition-opacity group-hover:opacity-100"><Upload className="size-3" />替换</span> : null}
|
{!readOnly ? <span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/55 px-1 py-1 text-[9px] text-white opacity-0 transition-opacity group-hover:opacity-100"><Upload className="size-3" />替换</span> : null}
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
94
web/src/lib/canvas/canvas-batch-storyboard.ts
Normal file
94
web/src/lib/canvas/canvas-batch-storyboard.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createStoryboardRow } from "@/lib/canvas/canvas-project-domain";
|
||||||
|
import type { CanvasBatchRow, CanvasBatchTableData, 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, index: number) {
|
||||||
|
const cells = row.cells || {};
|
||||||
|
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 columns[index] ? cells[columns[index].id] || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: string, fallback: number) {
|
||||||
|
const match = value.match(/\d+(?:\.\d+)?/);
|
||||||
|
const parsed = match ? Number(match[0]) : fallback;
|
||||||
|
return Number.isFinite(parsed) ? Math.max(1, Math.min(60, Math.round(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.map((row, index) => {
|
||||||
|
const base = table.storyboardRows?.find((item) => item.id === row.id) || table.storyboardRows?.[index];
|
||||||
|
const get = (field: StoryboardField) => cellValue(table, row, field, index) || 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, {
|
||||||
|
id: row.id,
|
||||||
|
shotNumber: numberValue(get("shotNumber"), index + 1),
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ export const STANDARD_IMAGE_SIZE_VALUES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DEFAULT_TEXT_REFERENCE_MAX_IMAGES = 16;
|
const DEFAULT_TEXT_REFERENCE_MAX_IMAGES = 16;
|
||||||
|
const DEFAULT_TEXT_REFERENCE_MAX_VIDEOS = 3;
|
||||||
|
|
||||||
function isKnownMultimodalTextModel(protocol?: ModelProtocol, model = "") {
|
function isKnownMultimodalTextModel(protocol?: ModelProtocol, model = "") {
|
||||||
// These model families currently expose vision through their text/chat
|
// These model families currently expose vision through their text/chat
|
||||||
@@ -298,8 +299,8 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
|||||||
promptMaxChars: 32000,
|
promptMaxChars: 32000,
|
||||||
maxImages: isKnownMultimodalTextModel(protocol, model) ? DEFAULT_TEXT_REFERENCE_MAX_IMAGES : 0,
|
maxImages: isKnownMultimodalTextModel(protocol, model) ? DEFAULT_TEXT_REFERENCE_MAX_IMAGES : 0,
|
||||||
maxImageBytes: isKnownMultimodalTextModel(protocol, model) ? 30 * 1024 * 1024 : 0,
|
maxImageBytes: isKnownMultimodalTextModel(protocol, model) ? 30 * 1024 * 1024 : 0,
|
||||||
maxVideos: 0,
|
maxVideos: isKnownMultimodalTextModel(protocol, model) ? DEFAULT_TEXT_REFERENCE_MAX_VIDEOS : 0,
|
||||||
maxVideoBytes: 0,
|
maxVideoBytes: isKnownMultimodalTextModel(protocol, model) ? 200 * 1024 * 1024 : 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const video: VideoCapabilityConfig = {
|
const video: VideoCapabilityConfig = {
|
||||||
@@ -412,8 +413,8 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
|||||||
if (!cost?.capabilityConfig) return fallback;
|
if (!cost?.capabilityConfig) return fallback;
|
||||||
const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig);
|
const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig);
|
||||||
const mergedText = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
|
const mergedText = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
|
||||||
const text = mergedText && isKnownMultimodalTextModel(cost?.protocol, modelName) && mergedText.references.maxImages === 0
|
const text = mergedText && isKnownMultimodalTextModel(cost?.protocol, modelName) && (mergedText.references.maxImages === 0 || mergedText.references.maxVideos === 0)
|
||||||
? { ...mergedText, references: { ...mergedText.references, maxImages: DEFAULT_TEXT_REFERENCE_MAX_IMAGES, maxImageBytes: mergedText.references.maxImageBytes || 30 * 1024 * 1024 } }
|
? { ...mergedText, references: { ...mergedText.references, maxImages: mergedText.references.maxImages || DEFAULT_TEXT_REFERENCE_MAX_IMAGES, maxImageBytes: mergedText.references.maxImageBytes || 30 * 1024 * 1024, maxVideos: mergedText.references.maxVideos || DEFAULT_TEXT_REFERENCE_MAX_VIDEOS, maxVideoBytes: mergedText.references.maxVideoBytes || 200 * 1024 * 1024 } }
|
||||||
: mergedText;
|
: mergedText;
|
||||||
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
|
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
|
||||||
const configuredImage = capabilityConfig.image;
|
const configuredImage = capabilityConfig.image;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { message } from "antd";
|
import { message } from "antd";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow, type StoryboardRow } from "@/types/canvas";
|
||||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||||
import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
|
import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
|
||||||
import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||||
import type { AiConfig } from "@/stores/use-config-store";
|
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 = `你是一个电商内容分析助手。用户会给你产品图片和一个任务描述。
|
const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你产品图片和一个任务描述。
|
||||||
请严格根据用户要求的数量生成数据;如果用户说“生成5个卖点”,就必须返回5行,每行一个独立卖点。
|
请严格根据用户要求的数量生成数据;如果用户说“生成5个卖点”,就必须返回5行,每行一个独立卖点。
|
||||||
@@ -26,6 +29,35 @@ const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户
|
|||||||
6. 不要把多个编号卖点塞进一个单元格;不要把整段分析只放进“任务提示词”
|
6. 不要把多个编号卖点塞进一个单元格;不要把整段分析只放进“任务提示词”
|
||||||
7. 列数建议 3-6 列,根据任务复杂度决定
|
7. 列数建议 3-6 列,根据任务复杂度决定
|
||||||
8. 只输出 JSON,不要有任何其他文字`;
|
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 = {
|
type ListGenerationResult = {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
@@ -71,6 +103,98 @@ export function parseListModeJson(text: string): ListGenerationResult | null {
|
|||||||
return null;
|
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: Number.isFinite(Number(row.sourceStartSeconds)) ? Math.max(0, Number(row.sourceStartSeconds) * 1000) : undefined,
|
||||||
|
sourceEndMs: Number.isFinite(Number(row.sourceEndSeconds)) ? Math.max(0, Number(row.sourceEndSeconds) * 1000) : undefined,
|
||||||
|
keyframeTimeMs: Number.isFinite(Number(row.keyframeTimeSeconds)) ? Math.max(0, Number(row.keyframeTimeSeconds) * 1000) : undefined,
|
||||||
|
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 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) {
|
function balancedJsonObjects(text: string) {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
let start = -1;
|
let start = -1;
|
||||||
@@ -182,16 +306,20 @@ export async function handleListGenerate({
|
|||||||
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
|
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
|
||||||
if (!sourceNode) return;
|
if (!sourceNode) return;
|
||||||
|
|
||||||
// Find connected image nodes
|
// Find connected media nodes. 视频列表模式不再强制要求图片;可使用连接的视频节点,
|
||||||
const connectedImageIds = connections
|
// 或从提示词中识别出直接粘贴的视频地址。
|
||||||
.filter((c) => c.toNodeId === sourceNodeId && c.fromNodeId !== sourceNodeId)
|
const connectedImageIds = connections
|
||||||
.map((c) => c.fromNodeId);
|
.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 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));
|
||||||
if (imageNodes.length === 0) {
|
const promptVideoUrls = videoNodes.length ? [] : videoUrlsFromPrompt(prompt);
|
||||||
message.warning("请先连接至少一张图片到当前文本节点");
|
const isVideoAnalysis = videoNodes.length > 0 || promptVideoUrls.length > 0;
|
||||||
return;
|
|
||||||
}
|
if (imageNodes.length === 0 && !isVideoAnalysis) {
|
||||||
|
message.warning("请先连接图片或视频到当前文本节点,也可以直接粘贴可访问的视频地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setRunningNodeId(sourceNodeId);
|
setRunningNodeId(sourceNodeId);
|
||||||
|
|
||||||
@@ -202,39 +330,103 @@ export async function handleListGenerate({
|
|||||||
// 实际请求却会发给不支持图片的旧模型。
|
// 实际请求却会发给不支持图片的旧模型。
|
||||||
const requirements: ModelRequirements = {
|
const requirements: ModelRequirements = {
|
||||||
capability: "text",
|
capability: "text",
|
||||||
input: { textCount: 1, imageCount: imageNodes.length, videoCount: 0, audioCount: 0, characterCount: 0 },
|
input: { textCount: 1, imageCount: imageNodes.length, videoCount: videoNodes.length + promptVideoUrls.length, audioCount: 0, characterCount: 0 },
|
||||||
options: modelRequestOptions(config, "text"),
|
options: modelRequestOptions(config, "text"),
|
||||||
};
|
};
|
||||||
const preferredModel = resolveCanvasGenerationModel(config, sourceNode.metadata?.model, "text") || resolveCanvasGenerationModel(config, config.textModel, "text");
|
const preferredModel = resolveCanvasGenerationModel(config, sourceNode.metadata?.model, "text") || resolveCanvasGenerationModel(config, config.textModel, "text");
|
||||||
if (!preferredModel) throw new Error("当前没有可用的文本模型,请先在模型设置中选择模型");
|
if (!preferredModel) throw new Error("当前没有可用的文本模型,请先在模型设置中选择模型");
|
||||||
const compatibleModel = resolveCompatibleModel(config, preferredModel, requirements);
|
const compatibleModel = resolveCompatibleModel(config, preferredModel, requirements);
|
||||||
if (!compatibleModel || modelCompatibilityError(config, compatibleModel, requirements)) {
|
if (!compatibleModel || modelCompatibilityError(config, compatibleModel, requirements)) {
|
||||||
throw new Error("当前文本模型不支持图片理解,请在节点底部选择支持图片输入的 Gemini 或其他多模态模型");
|
throw new Error(isVideoAnalysis ? "当前文本模型不支持视频理解,请选择支持视频输入的 Gemini、GPT 或豆包多模态模型" : "当前文本模型不支持图片理解,请在节点底部选择支持图片输入的 Gemini、GPT 或豆包多模态模型");
|
||||||
}
|
}
|
||||||
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: compatibleModel } };
|
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: compatibleModel } };
|
||||||
const listConfig = { ...buildGenerationConfig(config, sourceNodeForConfig, "text", requirements), model: compatibleModel };
|
const listConfig = { ...buildGenerationConfig(config, sourceNodeForConfig, "text", requirements), model: compatibleModel };
|
||||||
|
|
||||||
const referenceImages = imageNodes.map((node) => ({
|
const referenceImages = imageNodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.title || "image",
|
name: node.title || "image",
|
||||||
type: "image",
|
type: "image",
|
||||||
dataUrl: node.metadata?.content || "",
|
dataUrl: node.metadata?.content || "",
|
||||||
storageKey: node.metadata?.storageKey,
|
storageKey: node.metadata?.storageKey,
|
||||||
}));
|
}));
|
||||||
|
const referenceVideos: ReferenceVideo[] = [
|
||||||
const fullPrompt = `${LIST_MODE_SYSTEM_PROMPT}\n\n用户任务:${prompt}\n\n图片数量:${imageNodes.length} 张\n目标行数:${targetRowCount || "未指定,按任务判断"}`;
|
...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({
|
const result = await runBackendGenerationTask({
|
||||||
projectId,
|
projectId,
|
||||||
mode: "text",
|
mode: "text",
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
config: listConfig,
|
config: listConfig,
|
||||||
referenceImages,
|
referenceImages,
|
||||||
streamText: false,
|
referenceVideos,
|
||||||
});
|
streamText: false,
|
||||||
|
});
|
||||||
const text = result.text || "";
|
|
||||||
const parsed = parseListModeJson(text);
|
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) {
|
if (!parsed) {
|
||||||
message.error("AI 返回格式异常,请重试");
|
message.error("AI 返回格式异常,请重试");
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
|
|||||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||||
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
||||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
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 { storyboardRowsFromBatchTable } from "@/lib/canvas/canvas-batch-storyboard";
|
||||||
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||||
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
||||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||||
@@ -1988,6 +1989,60 @@ function InfiniteCanvasPage() {
|
|||||||
enqueueGenerationBatch,
|
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((row) => row.keyframeTimeMs ?? (row.sourceStartMs !== undefined && row.sourceEndMs !== undefined ? (row.sourceStartMs + row.sourceEndMs) / 2 : undefined)).filter((value): value is number => Number.isFinite(value));
|
||||||
|
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(scriptNode.id);
|
||||||
|
if (sourceVideo && keyframeTimesMs.length) {
|
||||||
|
const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs });
|
||||||
|
if (frameNodes.length) {
|
||||||
|
const frameByIndex = new Map(frameNodes.map((node, index) => [index, node.id]));
|
||||||
|
const updatedRows = rows.map((row, index) => ({ ...row, imageNodeId: frameByIndex.get(index) || row.imageNodeId }));
|
||||||
|
const withFrames = nodesRef.current.map((node) => node.id === scriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { ...node.metadata?.storyboard, rows: updatedRows, 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({
|
const handleRetryNode = useCanvasGenerationRetry({
|
||||||
projectId,
|
projectId,
|
||||||
domainProjectId: currentProject?.projectId,
|
domainProjectId: currentProject?.projectId,
|
||||||
@@ -2203,6 +2258,7 @@ function InfiniteCanvasPage() {
|
|||||||
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
|
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
|
||||||
onFillRows={() => fillRowsFromConnections(contentNode.id)}
|
onFillRows={() => fillRowsFromConnections(contentNode.id)}
|
||||||
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
||||||
|
onCreateStoryboard={() => createStoryboardFromBatchTable(contentNode.id)}
|
||||||
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
||||||
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
||||||
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
|
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
|
||||||
@@ -2290,6 +2346,7 @@ function InfiniteCanvasPage() {
|
|||||||
connections,
|
connections,
|
||||||
createAndGenerateScriptVideos,
|
createAndGenerateScriptVideos,
|
||||||
createScriptActionBoards,
|
createScriptActionBoards,
|
||||||
|
createStoryboardFromBatchTable,
|
||||||
createScriptImageNodes,
|
createScriptImageNodes,
|
||||||
createScriptVideoNodes,
|
createScriptVideoNodes,
|
||||||
currentProject?.directorScenes,
|
currentProject?.directorScenes,
|
||||||
|
|||||||
@@ -262,9 +262,9 @@ export function useCanvasMediaTools({
|
|||||||
setFrameDialogNodeId(null);
|
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;
|
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);
|
const progress = startUploadStatus("提取视频画面", "读取视频资源", params.timesMs.length + 2);
|
||||||
extractingVideoFramesNodeIdRef.current = node.id;
|
extractingVideoFramesNodeIdRef.current = node.id;
|
||||||
setExtractingVideoFramesNodeId(node.id);
|
setExtractingVideoFramesNodeId(node.id);
|
||||||
@@ -301,10 +301,12 @@ export function useCanvasMediaTools({
|
|||||||
const failedCount = captured.failures.length + uploadFailures.length;
|
const failedCount = captured.failures.length + uploadFailures.length;
|
||||||
progress.done(failedCount ? `已提取 ${frameNodes.length} 帧,${failedCount} 帧失败` : `已提取 ${frameNodes.length} 帧并创建图片节点`);
|
progress.done(failedCount ? `已提取 ${frameNodes.length} 帧,${failedCount} 帧失败` : `已提取 ${frameNodes.length} 帧并创建图片节点`);
|
||||||
if (failedCount) message.warning(`${failedCount} 个时间点提取失败,其余画面已创建`);
|
if (failedCount) message.warning(`${failedCount} 个时间点提取失败,其余画面已创建`);
|
||||||
|
return frameNodes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const details = error instanceof Error ? error.message : "视频画面提取失败";
|
const details = error instanceof Error ? error.message : "视频画面提取失败";
|
||||||
progress.fail(details);
|
progress.fail(details);
|
||||||
message.error(details);
|
message.error(details);
|
||||||
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
extractingVideoFramesNodeIdRef.current = null;
|
extractingVideoFramesNodeIdRef.current = null;
|
||||||
setExtractingVideoFramesNodeId(null);
|
setExtractingVideoFramesNodeId(null);
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ export type StoryboardRow = {
|
|||||||
motion: string;
|
motion: string;
|
||||||
timeBeats: string;
|
timeBeats: string;
|
||||||
imageGenerationPrompt: string;
|
imageGenerationPrompt: string;
|
||||||
videoMotionPrompt: string;
|
videoMotionPrompt: string;
|
||||||
|
/** 原视频拆解时的时间范围,用于自动抽取关键帧。 */
|
||||||
|
sourceStartMs?: number;
|
||||||
|
sourceEndMs?: number;
|
||||||
|
keyframeTimeMs?: number;
|
||||||
imagePromptTemplateVariables?: Record<string, string>;
|
imagePromptTemplateVariables?: Record<string, string>;
|
||||||
videoPromptTemplateVariables?: Record<string, string>;
|
videoPromptTemplateVariables?: Record<string, string>;
|
||||||
mustHave: string[];
|
mustHave: string[];
|
||||||
@@ -175,15 +179,21 @@ export type CanvasBatchRow = {
|
|||||||
/** AI-generated cell content keyed by column id (for aiGenerated tables). */
|
/** AI-generated cell content keyed by column id (for aiGenerated tables). */
|
||||||
cells?: Record<string, string>;
|
cells?: Record<string, string>;
|
||||||
};
|
};
|
||||||
export type CanvasBatchColumnType = "image" | "text";
|
export type CanvasBatchColumnType = "image" | "text";
|
||||||
|
export type CanvasBatchTableContentKind = "content" | "storyboard";
|
||||||
export type CanvasBatchReferenceColumn = {
|
export type CanvasBatchReferenceColumn = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: CanvasBatchColumnType;
|
type?: CanvasBatchColumnType;
|
||||||
};
|
};
|
||||||
export type CanvasBatchTableData = {
|
export type CanvasBatchTableData = {
|
||||||
operation: CanvasBatchOperation;
|
operation: CanvasBatchOperation;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
|
/** AI 输出的业务结构;分镜表会保留标准 StoryboardRow,避免依赖列名猜测。 */
|
||||||
|
contentKind?: CanvasBatchTableContentKind;
|
||||||
|
storyboardRows?: StoryboardRow[];
|
||||||
|
storyboardTitle?: string;
|
||||||
|
storyboardSourceNodeIds?: string[];
|
||||||
/** Optional prompt override applied to every batch row while non-empty. */
|
/** Optional prompt override applied to every batch row while non-empty. */
|
||||||
globalPrompt?: string;
|
globalPrompt?: string;
|
||||||
referenceColumns?: CanvasBatchReferenceColumn[];
|
referenceColumns?: CanvasBatchReferenceColumn[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { parseListModeJson, requestedListRowCount } from "@/pages/canvas/list-mode-generator";
|
import { parseListModeJson, parseStoryboardJson, requestedListRowCount } from "@/pages/canvas/list-mode-generator";
|
||||||
|
|
||||||
test("extracts the requested row count from natural language", () => {
|
test("extracts the requested row count from natural language", () => {
|
||||||
expect(requestedListRowCount("帮我生成5个卖点")).toBe(5);
|
expect(requestedListRowCount("帮我生成5个卖点")).toBe(5);
|
||||||
@@ -34,4 +34,10 @@ describe("list mode response parsing", () => {
|
|||||||
expect(parseListModeJson("模型暂时无法分析")).toBeNull();
|
expect(parseListModeJson("模型暂时无法分析")).toBeNull();
|
||||||
expect(parseListModeJson('{"columns":[],"rows":[]}')).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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user