feat(canvas): 分镜脚本 - 关联画布资产并自动物化视频引用
This commit is contained in:
1 parent
03597c6d51
commit
625a97560b
21 files changed
+684
-249
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user