feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路

This commit is contained in:
ddCat committed 2026-08-04 15:03:41 +08:00
1 parent 98382ba976
commit d4018123e1
46 files changed
+2666 -571

No files matched your search

@@ -145,7 +145,7 @@ export async function executeImageGeneration({
config: { ...generationConfig, count: "1" },
referenceImages,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions },
metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables },
onTaskCreated: (task) => bindGenerationTask(targetId, task),
});
const image = result.images?.[0];
@@ -84,7 +84,7 @@ export async function executeVideoGeneration({
startGenerationRequest(videoId, nodeId, nodeId, controller);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
+10 -4
View File
@@ -26,7 +26,7 @@ import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asse
import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar";
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
@@ -434,7 +434,7 @@ function InfiniteCanvasPage() {
});
useEffect(() => {
const preset = canvasStylePresets.find((item) => item.id === linkedProjectQuery.data?.project.stylePresetId);
const preset = resolveCanvasStylePreset(linkedProjectQuery.data?.project.stylePresetId);
if (!projectLoaded || !preset) return;
const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
const nextMetadata = {
@@ -1259,7 +1259,8 @@ function InfiniteCanvasPage() {
onCreateImageNodes={() => createScriptImageNodes(contentNode.id)}
onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)}
onGenerateImages={() => void generateScriptImages(contentNode.id, rowIds)}
onGenerateVideos={() => workspaceMode === "simple" ? void createAndGenerateScriptVideos(contentNode.id) : void generateScriptVideos(contentNode.id, rowIds)}
onGenerateVideos={() => contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds)}
onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })}
onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)}
onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)}
onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)}
@@ -1690,7 +1691,12 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => node.id === activeScriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } : node));
}}
onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
onGenerateVideos={(rowIds) => activeScriptNode && void generateScriptVideos(activeScriptNode.id, rowIds)}
onGenerateVideos={(rowIds) => {
if (!activeScriptNode) return;
if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
}}
onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
/>
{directorNodeId && activeDirectorScene ? (
@@ -4,7 +4,7 @@ import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
@@ -129,7 +129,7 @@ export function useCanvasConnectionController({
: type === CanvasNodeType.Script && scriptPrompt
? { prompt: scriptPrompt, composerContent: scriptPrompt }
: type === CanvasNodeType.Video && storyboardRow
? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
? { prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(storyboardRow, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
: undefined;
const sourceNodeForQuickCreate = pending.quick ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
const spec = getNodeSpec(type);
@@ -148,7 +148,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
}
if (node.type === CanvasNodeType.Video) {
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
@@ -187,7 +187,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
return;
}
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [] }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploadedImage = await uploadImage(image.dataUrl);
@@ -132,7 +132,10 @@ export function useCanvasNodeEditor({
const metadata = moderationFailure && prompt !== previousPrompt
? resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle")
: node.metadata;
return { ...node, metadata: hasExistingContent ? { ...metadata, composerContent: prompt } : { ...metadata, prompt, composerContent: prompt } };
const promptTemplateMetadata = prompt !== previousPrompt && metadata.promptTemplateOperation
? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
: {};
return { ...node, metadata: hasExistingContent ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt } };
}));
}, [setNodes]);
+72 -22
View File
@@ -15,8 +15,10 @@ import {
createStoryboardRow,
expandStoryboardTextMentions,
storyboardRowsFromTask,
storyboardPromptTemplateMetadata,
} from "@/lib/canvas/canvas-project-domain";
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
import { generationErrorMessage } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
@@ -86,18 +88,20 @@ export function useCanvasStoryboard({
const replaceScriptRows = useCallback((nodeId: string, rows: StoryboardRow[]) => {
const rowIds = new Set(rows.map((row) => `row:${row.id}`));
const previousRows = new Map((nodesRef.current.find((node) => node.id === nodeId)?.metadata?.storyboard?.rows || []).map((row) => [row.id, row]));
const nextRows = rows.map((row) => invalidateEditedPromptVariables(previousRows.get(row.id), row));
setConnections((current) => current
.filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId || rowIds.has(connection.fromHandleId))
.filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId || rowIds.has(connection.toHandleId)));
updateScriptRows(nodeId, () => rows);
}, [setConnections, updateScriptRows]);
updateScriptRows(nodeId, () => nextRows);
}, [nodesRef, setConnections, updateScriptRows]);
const addScriptRow = useCallback((nodeId: string) => {
updateScriptRows(nodeId, (rows) => [...rows, createStoryboardRow(rows.length + 1)]);
}, [updateScriptRows]);
const updateScriptRow = useCallback((nodeId: string, rowId: string, patch: Partial<StoryboardRow>) => {
updateScriptRows(nodeId, (rows) => rows.map((row) => row.id === rowId ? { ...row, ...patch } : row));
updateScriptRows(nodeId, (rows) => rows.map((row) => row.id === rowId ? invalidateEditedPromptVariables(row, { ...row, ...patch }) : row));
}, [updateScriptRows]);
const removeScriptRow = useCallback((nodeId: string, rowId: string) => {
@@ -109,6 +113,13 @@ export function useCanvasStoryboard({
const generateScriptRows = useCallback(async (nodeId: string, prompt: string) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
if (!scriptNode || !prompt.trim()) return;
let storyboardContext: ReturnType<typeof resolveStoryboardGenerationContext>;
try {
storyboardContext = resolveStoryboardGenerationContext(nodesRef.current);
} catch (error) {
message.warning(error instanceof Error ? error.message : "分镜上下文不完整");
return;
}
const shotDuration = scriptNode.metadata?.storyboardShotDuration || "auto";
const shotDurationSeconds = shotDuration === "auto" ? 0 : Number(shotDuration);
const shotCount = scriptNode.metadata?.storyboardShotCount || "auto";
@@ -130,6 +141,8 @@ export function useCanvasStoryboard({
input: {
canvasSnapshot: { nodes: nodesRef.current, connections: connectionsRef.current },
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
projectStyle: storyboardContext.projectStyle,
characters: storyboardContext.characters,
shotDurationSeconds,
shotCount: requestedShotCount,
config: backendProviderConfig(generationConfig),
@@ -181,8 +194,8 @@ export function useCanvasStoryboard({
const existing = row.imageNodeId ? nextNodes.find((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image) : undefined;
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
const imageNode = existing
? { ...existing, metadata: { ...existingMetadata, prompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } }
: createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE });
? { ...existing, metadata: { ...existingMetadata, prompt, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } }
: createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE });
if (!existing) {
imageNode.title = `镜头 ${row.shotNumber} · 分镜图`;
nextNodes.push(imageNode);
@@ -194,6 +207,7 @@ export function useCanvasStoryboard({
const referenceIds = new Set([
...(scriptNode.metadata?.storyboard?.referenceNodeIds || []),
...(row.referenceNodeIds || []),
...characterReferenceNodeIds(row, nextNodes),
...nextConnections.filter((connection) => connection.toNodeId === scriptNode.id && connection.toHandleId === `row:${row.id}`).map((connection) => connection.fromNodeId),
]);
referenceIds.forEach((referenceId) => {
@@ -272,14 +286,20 @@ export function useCanvasStoryboard({
if (existingIndex >= 0) {
const existing = nextNodes[existingIndex];
const existingMetadata = existing.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing.metadata);
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false).forEach((referenceId) => {
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === existing.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: existing.id });
});
videoNodeByRowId.set(row.id, existing.id);
return;
}
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
nextNodes.push(videoNode);
nextConnections.push({ id: nanoid(), fromNodeId: scriptNode.id, toNodeId: videoNode.id, fromHandleId: `row:${row.id}` });
storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false).forEach((referenceId) => {
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: videoNode.id });
});
videoNodeByRowId.set(row.id, videoNode.id);
createdCount += 1;
});
@@ -302,14 +322,15 @@ export function useCanvasStoryboard({
if (!silent) message.success(createdCount ? `已创建 ${createdCount} 个视频节点` : "已同步现有视频节点的提示词");
}, [connectionsRef, message, nodesRef, setConnections, setNodes]);
const createAndGenerateScriptVideos = useCallback(async (nodeId: string) => {
const createAndGenerateScriptVideos = useCallback(async (nodeId: string, rowIds?: string[]) => {
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, videoModel)) {
navigateToSettings({ continueCreation: true });
return;
}
let scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
const rows = scriptNode?.metadata?.storyboard?.rows || [];
const allRows = scriptNode?.metadata?.storyboard?.rows || [];
const rows = rowIds?.length ? allRows.filter((row) => rowIds.includes(row.id)) : allRows;
const describedRows = rows.filter((row) => Boolean((row.videoMotionPrompt || row.plotDescription).trim()));
const activeNodeIds = scriptNode ? activeGenerationBatchNodeIds(scriptNode, "storyboard_video") : new Set<string>();
const targetRows = describedRows.filter((row) => {
@@ -332,19 +353,17 @@ export function useCanvasStoryboard({
if (!videoNode || videoNode.metadata?.content) return [];
const prompt = (currentRow.videoMotionPrompt || currentRow.plotDescription).trim();
if (!prompt) return [];
const imageNode = currentRow.imageNodeId ? nodesRef.current.find((node) => node.id === currentRow.imageNodeId && node.type === CanvasNodeType.Image && node.metadata?.content) : undefined;
return [{ row: currentRow, videoNode, imageNode, prompt }];
return [{ row: currentRow, videoNode, prompt }];
});
const targetById = new Map(targets.map((target) => [target.videoNode.id, target]));
const nextNodes = nodesRef.current.map((node) => {
const target = targetById.get(node.id);
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, generationMode: "video" as const, videoEditOperation: target.imageNode ? "image_to_video" as const : "text_to_video" as const } } : node;
});
const nextConnections = [...connectionsRef.current];
targets.forEach((target) => {
const imageNode = target.imageNode;
if (imageNode && !nextConnections.some((connection) => connection.fromNodeId === imageNode.id && connection.toNodeId === target.videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: imageNode.id, toNodeId: target.videoNode.id });
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, ...storyboardPromptTemplateMetadata(target.row, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, videoStartFrameNodeId: undefined } } : node;
});
const dedicatedFirstFrameConnections = new Set(targets
.filter((target) => Boolean(target.row.imageNodeId))
.map((target) => `${target.row.imageNodeId}:${target.videoNode.id}`));
const nextConnections = connectionsRef.current.filter((connection) => !dedicatedFirstFrameConnections.has(`${connection.fromNodeId}:${connection.toNodeId}`));
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
@@ -405,8 +424,8 @@ export function useCanvasStoryboard({
const rows = (scriptNode?.metadata?.storyboard?.rows || []).filter((row) => rowIds.includes(row.id));
if (!scriptNode || !rows.length) return;
const readyRows = rows.filter((row) => row.imageNodeId && nodesRef.current.some((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image && node.metadata?.content));
if (!readyRows.length) return message.warning("请先生成选中镜头的分镜图");
if (readyRows.length !== rows.length) message.warning(`${rows.length - readyRows.length} 个镜头没有可用分镜图,已跳过`);
if (!readyRows.length) return message.warning("请先生成并检查选中镜头的首帧");
if (readyRows.length !== rows.length) return message.warning(`${rows.length - readyRows.length} 个选中镜头还没有可用首帧,请全部生成并检查后再确认`);
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, videoModel)) {
navigateToSettings({ continueCreation: true });
@@ -434,8 +453,8 @@ export function useCanvasStoryboard({
const existing = row.videoNodeId ? nextNodes.find((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : undefined;
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
const videoNode = existing
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, seconds: String(row.durationSeconds) } }
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } }
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", videoStartFrameNodeId: row.imageNodeId, status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
if (!existing) {
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
nextNodes.push(videoNode);
@@ -444,7 +463,9 @@ export function useCanvasStoryboard({
const existingIndex = nextNodes.findIndex((node) => node.id === existing.id);
nextNodes[existingIndex] = videoNode;
}
if (!nextConnections.some((connection) => connection.fromNodeId === row.imageNodeId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: row.imageNodeId!, toNodeId: videoNode.id });
storyboardRowReferenceNodeIds(currentScriptNode, row, nextNodes, nextConnections, true).forEach((referenceId) => {
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: videoNode.id });
});
targets.push({ row, node: videoNode, prompt });
});
nodesRef.current = nextNodes;
@@ -470,6 +491,35 @@ export function useCanvasStoryboard({
};
}
function characterReferenceNodeIds(row: StoryboardRow, nodes: CanvasNodeData[]) {
const assetIds = new Set(row.characters.map((character) => character.characterAssetId).filter((assetId): assetId is string => Boolean(assetId)));
return nodes
.filter((node) => node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId) && assetIds.has(node.metadata.characterAssetId!))
.map((node) => node.id);
}
function invalidateEditedPromptVariables(previous: StoryboardRow | undefined, next: StoryboardRow) {
if (!previous) return next;
return {
...next,
imagePromptTemplateVariables: next.imageGenerationPrompt === previous.imageGenerationPrompt ? next.imagePromptTemplateVariables : undefined,
videoPromptTemplateVariables: next.videoMotionPrompt === previous.videoMotionPrompt ? next.videoPromptTemplateVariables : undefined,
};
}
function storyboardRowReferenceNodeIds(scriptNode: CanvasNodeData, row: StoryboardRow, nodes: CanvasNodeData[], connections: CanvasConnection[], includeFirstFrame: boolean) {
const referenceIds = new Set([
...(scriptNode.metadata?.storyboard?.referenceNodeIds || []),
...(row.referenceNodeIds || []),
...characterReferenceNodeIds(row, nodes),
...connections.filter((connection) => connection.toNodeId === scriptNode.id && connection.toHandleId === `row:${row.id}`).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);
}
function activeGenerationBatchNodeIds(node: CanvasNodeData, mode: CanvasGenerationBatchMode) {
return new Set((node.metadata?.generationBatches || [])
.filter((batch) => batch.mode === mode)