feat(canvas): 分镜脚本 - 关联画布资产并自动物化视频引用

This commit is contained in:
ddCat committed 2026-08-25 11:24:47 +08:00
1 parent 03597c6d51
commit 625a97560b
21 files changed
+684 -249

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ function copyStoryboardRow(row: StoryboardRow, idMap: ReadonlyMap<string, string
...character,
characterImageNodeId: remapReferenceId(character.characterImageNodeId, idMap),
})),
referenceNodeIds: remapReferenceIds(row.referenceNodeIds || [], idMap) || [],
assetBindings: (row.assetBindings || []).map((binding) => ({ ...binding, nodeId: remapReferenceId(binding.nodeId, idMap)! })),
imageNodeId,
videoNodeId,
status: hasCopiedOutput ? row.status : "idle",
+19 -8
View File
@@ -1,5 +1,7 @@
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { normalizeStoryboardAssetBindings } from "@/lib/canvas/canvas-storyboard-assets";
import { bindingForConnectedNode, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
@@ -45,7 +47,7 @@ export function createCanvasNode(type: CanvasNodeType, position: Position, metad
width: spec.width,
height: spec.height,
metadata: type === CanvasNodeType.Script
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"], referenceNodeIds: [] } }
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"], referenceNodeIds: [] } }
: { ...spec.metadata, ...metadata, ...(type === CanvasNodeType.Drawing ? { drawingId: metadata?.drawingId || `${id}-document` } : {}) },
};
}
@@ -74,7 +76,7 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
optionalDetails: [],
continuityOut: "",
negativePrompt: "",
referenceNodeIds: [],
assetBindings: [],
status: "idle",
...patch,
};
@@ -90,7 +92,12 @@ export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "imag
export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] {
return Array.from(new Set([
...(columns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"]),
"shotNumber",
"durationSeconds",
"videoMotionPrompt",
"dialogue",
"assets",
...(columns || []),
"shotSize",
"narrativeIntent",
"viewerPOV",
@@ -115,7 +122,7 @@ export function storyboardRowsFromTask(task: GenerationTask) {
id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`,
shotNumber: index + 1,
status: "idle",
referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [],
assetBindings: normalizeStoryboardAssetBindings(row.assetBindings),
});
next.characters = Array.isArray(row.characters) ? row.characters : [];
next.mustHave = Array.isArray(row.mustHave) ? row.mustHave : [];
@@ -225,13 +232,15 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
if (!scriptNodeId || !linkedNode || !scriptNode) return nodes;
const row = rowId ? scriptNode.metadata?.storyboard?.rows.find((item) => item.id === rowId) : undefined;
const videoPrompt = row ? (row.videoMotionPrompt || row.plotDescription).trim() : "";
const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false)) : "";
return nodes.map((node) => {
if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) {
return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } };
return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoComposerContent, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } };
}
if (node.id !== scriptNodeId || node.type !== CanvasNodeType.Script) return node;
const storyboard = node.metadata?.storyboard;
const binding = bindingForConnectedNode(linkedNode);
return {
...node,
metadata: {
@@ -239,8 +248,10 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
storyboard: {
rows: (storyboard?.rows || []).map((item) => item.id !== rowId ? item : scriptNodeId === connection.fromNodeId
? { ...item, imageNodeId: linkedNode.type === CanvasNodeType.Image ? linkedNode.id : item.imageNodeId, videoNodeId: linkedNode.type === CanvasNodeType.Video ? linkedNode.id : item.videoNodeId }
: { ...item, referenceNodeIds: Array.from(new Set([...(item.referenceNodeIds || []), linkedNode.id])) }),
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
: binding && !(item.assetBindings || []).some((candidate) => candidate.nodeId === linkedNode.id)
? { ...item, assetBindings: [...(item.assetBindings || []), binding] }
: item),
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
referenceNodeIds: handleId === "storyboard:context" ? Array.from(new Set([...(storyboard?.referenceNodeIds || []), linkedNode.id])) : storyboard?.referenceNodeIds || [],
},
},
@@ -402,7 +413,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<str
referenceNodeIds: storyboard.referenceNodeIds.filter((id) => !removedIds.has(id)),
rows: storyboard.rows.map((row) => ({
...row,
referenceNodeIds: (row.referenceNodeIds || []).filter((id) => !removedIds.has(id)),
assetBindings: (row.assetBindings || []).filter((binding) => !removedIds.has(binding.nodeId)),
imageNodeId: row.imageNodeId && !removedIds.has(row.imageNodeId) ? row.imageNodeId : undefined,
videoNodeId: row.videoNodeId && !removedIds.has(row.videoNodeId) ? row.videoNodeId : undefined,
})),
+1 -1
View File
@@ -53,7 +53,7 @@ export function createShortDramaPipeline(center: Position) {
workflowTitle: "分镜脚本",
storyboard: {
rows: [],
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
visibleColumns: ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
referenceNodeIds: [],
},
});
@@ -0,0 +1,68 @@
import { CanvasNodeType, type CanvasNodeData, type StoryboardAssetBinding, type StoryboardAssetRole } from "@/types/canvas";
export type StoryboardAssetCatalogItem = {
id: string;
title: string;
type: "image" | "video" | "audio" | "character";
category?: string;
tags: string[];
prompt: string;
characterAssetId?: string;
characterVersionId?: string;
};
const OUTPUT_WORKFLOW_KINDS = new Set(["shot", "action_board", "final"]);
const STORYBOARD_ASSET_ROLES = new Set<StoryboardAssetRole>(["character", "environment", "wardrobe", "prop", "weapon", "style", "motion", "audio"]);
export function buildStoryboardAssetCatalog(nodes: CanvasNodeData[]): StoryboardAssetCatalogItem[] {
return nodes.flatMap((node): StoryboardAssetCatalogItem[] => {
const type = storyboardAssetType(node);
if (!type || OUTPUT_WORKFLOW_KINDS.has(node.metadata?.workflowKind || "")) return [];
if (!node.metadata?.content && !node.metadata?.storageKey && !node.metadata?.assetId && type !== "character") return [];
const prompt = compactStoryboardAssetText(node.metadata?.prompt || node.metadata?.workflowDescription || node.metadata?.characterPrompt || "");
return [{
id: node.id,
title: compactStoryboardAssetText(node.title, 120) || "未命名资产",
type,
category: node.metadata?.assetCategory,
tags: Array.from(new Set((node.metadata?.assetTags || []).map((tag) => compactStoryboardAssetText(tag, 64)).filter(Boolean))).slice(0, 12),
prompt,
characterAssetId: node.metadata?.characterAssetId,
characterVersionId: node.metadata?.characterVersionId,
}];
}).slice(0, 60);
}
export function storyboardAssetRoleForNode(node: CanvasNodeData): StoryboardAssetRole | null {
if (node.metadata?.workflowKind === "character" || node.metadata?.assetCategory === "character") return "character";
if (node.type === CanvasNodeType.Audio) return "audio";
if (node.type === CanvasNodeType.Video) return "motion";
const category = node.metadata?.assetCategory;
if (category === "environment" || category === "wardrobe" || category === "prop" || category === "weapon" || category === "style") return category;
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing) return "style";
return null;
}
export function normalizeStoryboardAssetBindings(bindings: StoryboardAssetBinding[] | undefined, nodes?: CanvasNodeData[]) {
const nodeIds = nodes ? new Set(nodes.map((node) => node.id)) : null;
const seen = new Set<string>();
return (bindings || []).flatMap((binding): StoryboardAssetBinding[] => {
const nodeId = String(binding?.nodeId || "").trim();
if (!nodeId || seen.has(nodeId) || !STORYBOARD_ASSET_ROLES.has(binding.role) || (nodeIds && !nodeIds.has(nodeId))) return [];
seen.add(nodeId);
return [{ nodeId, role: binding.role, priority: Math.max(0, Math.min(100, Math.round(Number(binding.priority) || 0))) }];
}).sort((left, right) => right.priority - left.priority);
}
function storyboardAssetType(node: CanvasNodeData): StoryboardAssetCatalogItem["type"] | null {
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId && node.metadata.characterVersionId) return "character";
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing) return "image";
if (node.type === CanvasNodeType.Video) return "video";
if (node.type === CanvasNodeType.Audio) return "audio";
return null;
}
function compactStoryboardAssetText(value: string, limit = 600) {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > limit ? `${normalized.slice(0, limit)}…` : normalized;
}
@@ -0,0 +1,76 @@
import { nanoid } from "nanoid";
import { storyboardAssetRoleForNode } from "@/lib/canvas/canvas-storyboard-assets";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardAssetBinding, type StoryboardRow } from "@/types/canvas";
export function storyboardRowReferenceNodeIds(
scriptNode: CanvasNodeData,
row: StoryboardRow,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
includeFirstFrame: boolean,
targetNodeId?: string,
) {
const characterAssetIds = new Set((row.characters || []).map((character) => character.characterAssetId).filter((assetId): assetId is string => Boolean(assetId)));
const characterNodeIds = nodes
.filter((node) => node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId) && characterAssetIds.has(node.metadata.characterAssetId!))
.map((node) => node.id);
const referenceIds = new Set([
...(scriptNode.metadata?.storyboard?.referenceNodeIds || []),
...(row.assetBindings || []).map((binding) => binding.nodeId),
...characterNodeIds,
...connections.filter((connection) => connection.toNodeId === scriptNode.id && connection.toHandleId === `row:${row.id}`).map((connection) => connection.fromNodeId),
...(targetNodeId ? connections.filter((connection) => !connection.relation && connection.toNodeId === targetNodeId).map((connection) => connection.fromNodeId) : []),
...(includeFirstFrame && row.imageNodeId ? [row.imageNodeId] : []),
]);
if (!includeFirstFrame && row.imageNodeId) referenceIds.delete(row.imageNodeId);
referenceIds.delete(scriptNode.id);
return Array.from(referenceIds).filter((nodeId) => nodes.some((node) => node.id === nodeId));
}
export function storyboardComposerContent(prompt: string, referenceNodeIds: string[]) {
const mentions = Array.from(new Set(referenceNodeIds)).map((nodeId) => `@[node:${nodeId}]`);
return mentions.length ? [`参考资产:${mentions.join(" ")}`, prompt.trim()].filter(Boolean).join("\n") : prompt.trim();
}
export function reconcileStoryboardTargetConnections(
connections: CanvasConnection[],
scriptNode: CanvasNodeData,
row: StoryboardRow,
targetNodeId: string,
referenceNodeIds: string[],
) {
const desired = new Set(referenceNodeIds.filter((nodeId) => nodeId !== targetNodeId && nodeId !== scriptNode.id));
const next = connections.filter((connection) => {
if (connection.storyboardRowId !== row.id || connection.toNodeId !== targetNodeId) return true;
if (connection.relation === "storyboard-output") return connection.fromNodeId === scriptNode.id;
if (connection.relation === "storyboard-asset-reference") return desired.has(connection.fromNodeId);
return true;
});
if (!next.some((connection) => connection.fromNodeId === scriptNode.id && connection.toNodeId === targetNodeId && connection.fromHandleId === `row:${row.id}`)) {
next.push({ id: nanoid(), fromNodeId: scriptNode.id, toNodeId: targetNodeId, fromHandleId: `row:${row.id}`, relation: "storyboard-output", storyboardRowId: row.id });
}
desired.forEach((fromNodeId) => {
if (!next.some((connection) => connection.fromNodeId === fromNodeId && connection.toNodeId === targetNodeId)) {
next.push({ id: nanoid(), fromNodeId, toNodeId: targetNodeId, relation: "storyboard-asset-reference", storyboardRowId: row.id });
}
});
return next;
}
export function bindingForConnectedNode(node: CanvasNodeData): StoryboardAssetBinding | null {
const role = storyboardAssetRoleForNode(node);
return role ? { nodeId: node.id, role, priority: defaultRolePriority(role) } : null;
}
export function isStoryboardPreviewAsset(node: CanvasNodeData) {
return node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio || node.metadata?.workflowKind === "character";
}
function defaultRolePriority(role: StoryboardAssetBinding["role"]) {
if (role === "character") return 100;
if (role === "environment") return 90;
if (role === "prop" || role === "weapon" || role === "wardrobe") return 80;
if (role === "motion" || role === "audio") return 70;
return 60;
}
@@ -22,7 +22,7 @@ export function upsertProjectChapterStoryboard(
const storyboard: StoryboardData = {
rows,
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
referenceNodeIds: existing?.metadata?.storyboard?.referenceNodeIds || [],
};
const scriptNode: CanvasNodeData = existing
@@ -75,7 +75,7 @@ function createChapterStoryboardNode(nodes: CanvasNodeData[], unit: Pick<Project
chapterTitle: unit.title,
storyboard: {
rows,
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
visibleColumns: ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
referenceNodeIds: [],
},
});