Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-short-drama.ts
T

147 lines
7.8 KiB
TypeScript

import { nanoid } from "nanoid";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { scopedLocalStorage } from "@/lib/user-scope";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas";
const SHORT_DRAMA_GUIDE_STORAGE_KEY = "canvas-short-drama-guide-v1";
export type CanvasShortDramaStepId = "style" | "story" | "storyboard" | "video" | "final";
export type CanvasShortDramaStepStatus = "pending" | "current" | "completed";
export type CanvasShortDramaStep = {
id: CanvasShortDramaStepId;
label: string;
status: CanvasShortDramaStepStatus;
nodeId?: string;
};
export type CanvasShortDramaProgress = {
active: boolean;
completed: boolean;
completedCount: number;
steps: CanvasShortDramaStep[];
};
export function createShortDramaPipeline(center: Position) {
const styleNode = createCanvasNode(CanvasNodeType.Text, { x: center.x - 760, y: center.y - 170 }, {
content: "",
status: "idle",
workflowKind: "styleboard",
workflowTitle: "项目画风",
workflowDescription: "待选择",
fontSize: 14,
});
styleNode.title = "项目画风 · 待选择";
styleNode.width = 360;
styleNode.height = 220;
const storyNode = createCanvasNode(CanvasNodeType.Text, { x: center.x - 760, y: center.y + 170 }, {
content: "",
status: "idle",
workflowKind: "story_input",
workflowTitle: "故事输入",
workflowDescription: "题材、角色、冲突和结局方向",
fontSize: 14,
});
storyNode.title = "故事梗概";
storyNode.width = 420;
storyNode.height = 260;
const scriptNode = createCanvasNode(CanvasNodeType.Script, { x: center.x + 180, y: center.y }, {
status: "idle",
workflowKind: "storyboard",
workflowTitle: "分镜脚本",
storyboard: {
rows: [],
visibleColumns: ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
referenceNodeIds: [],
},
});
scriptNode.title = "分镜脚本 · 待生成";
const connections: CanvasConnection[] = [
{ id: nanoid(), fromNodeId: styleNode.id, toNodeId: scriptNode.id, toHandleId: "storyboard:context" },
{ id: nanoid(), fromNodeId: storyNode.id, toNodeId: scriptNode.id, toHandleId: "storyboard:context" },
];
return { nodes: [styleNode, storyNode, scriptNode], connections, styleNodeId: styleNode.id, storyNodeId: storyNode.id, scriptNodeId: scriptNode.id };
}
export function deriveShortDramaProgress(nodes: CanvasNodeData[], connections: CanvasConnection[]): CanvasShortDramaProgress {
const storyInputNode = nodes.find((node) => node.metadata?.workflowKind === "story_input");
const storyboardScripts = nodes.filter((node) => node.type === CanvasNodeType.Script && (node.metadata?.workflowKind === "storyboard" || Boolean(storyInputNode)));
const agentScriptNode = nodes.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "script");
const shotNodes = nodes.filter((node) => node.metadata?.workflowKind === "shot");
const finalNodes = nodes.filter((node) => node.metadata?.workflowKind === "final");
const hasManualPipeline = Boolean(storyInputNode || storyboardScripts.some((node) => node.metadata?.workflowKind === "storyboard"));
const hasAgentPipeline = Boolean(agentScriptNode && shotNodes.length && finalNodes.length);
const active = hasManualPipeline || hasAgentPipeline;
const scriptIds = new Set(storyboardScripts.map((node) => node.id));
const isConnectedToStoryboard = (nodeId: string) => connections.some((connection) => connection.fromNodeId === nodeId && scriptIds.has(connection.toNodeId));
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
const storyNode = storyInputNode || agentScriptNode;
const scriptNode = storyboardScripts.find((node) => meaningfulStoryboardRows(node).length > 0) || storyboardScripts[0];
const meaningfulRows = scriptNode ? meaningfulStoryboardRows(scriptNode) : [];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const successfulVideoNodeIds = new Set(nodes.filter(isSuccessfulVideoNode).map((node) => node.id));
const completedShotVideos = nodes.filter((node) => isSuccessfulVideoNode(node) && (node.metadata?.workflowKind === "shot" || connections.some((connection) => shotNodes.some((shot) => shot.id === connection.fromNodeId) && connection.toNodeId === node.id)));
const finalNode = finalNodes.find((node) => node.metadata?.status === "success" && Boolean(node.metadata.content));
const storyText = (storyNode?.metadata?.content || "").trim();
// 手工流水线要求输入真实连到分镜脚本;Agent 协议的风格板和剧本没有这条连线,按领域节点本身判断。
const styleDone = Boolean((styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim() && (hasAgentPipeline || !scriptNode || isConnectedToStoryboard(styleNode!.id)));
const storyDone = Boolean(storyText && storyNode && (storyNode === agentScriptNode || !scriptNode || isConnectedToStoryboard(storyNode.id)));
const storyboardDone = meaningfulRows.length > 0 || shotNodes.length > 0;
const rowsWithVideo = meaningfulRows.filter((row) => row.videoNodeId && nodeById.get(row.videoNodeId)?.type === CanvasNodeType.Video);
const videoDone = meaningfulRows.length > 0
? rowsWithVideo.length === meaningfulRows.length && rowsWithVideo.every((row) => {
const videoNode = nodeById.get(row.videoNodeId!);
return videoNode?.metadata?.status === "success" && Boolean(videoNode.metadata.content);
})
: shotNodes.length > 0 && shotNodes.every((shot) => connections.some((connection) => connection.fromNodeId === shot.id && successfulVideoNodeIds.has(connection.toNodeId)));
const done = [styleDone, storyDone, storyboardDone, videoDone, Boolean(finalNode)];
const firstIncomplete = done.findIndex((value) => !value);
const firstShotNode = shotNodes[0];
const definitions: Array<{ id: CanvasShortDramaStepId; label: string; nodeId?: string }> = [
{ id: "style", label: "选择画风", nodeId: styleNode?.id },
{ id: "story", label: "输入故事", nodeId: storyNode?.id },
{ id: "storyboard", label: "生成分镜", nodeId: scriptNode?.id || firstShotNode?.id },
{ id: "video", label: "生成视频", nodeId: scriptNode?.id || firstShotNode?.id || completedShotVideos[0]?.id },
{ id: "final", label: "合并成片", nodeId: finalNode?.id || finalNodes[0]?.id || completedShotVideos[0]?.id },
];
const steps = definitions.map((step, index): CanvasShortDramaStep => ({
...step,
status: done[index] ? "completed" : index === firstIncomplete ? "current" : "pending",
}));
return {
active,
completed: done.every(Boolean),
completedCount: done.filter(Boolean).length,
steps,
};
}
export function readShortDramaGuideDismissed() {
try {
return scopedLocalStorage.getItem(SHORT_DRAMA_GUIDE_STORAGE_KEY) === "dismissed";
} catch (error) {
console.warn("读取短剧导引状态失败", error);
return false;
}
}
export function persistShortDramaGuideDismissed() {
try {
scopedLocalStorage.setItem(SHORT_DRAMA_GUIDE_STORAGE_KEY, "dismissed");
} catch (error) {
console.warn("保存短剧导引状态失败", error);
}
}
function meaningfulStoryboardRows(node: CanvasNodeData) {
return (node.metadata?.storyboard?.rows || []).filter((row) => Boolean((row.plotDescription || row.imageGenerationPrompt || row.videoMotionPrompt).trim()));
}
function isSuccessfulVideoNode(node: CanvasNodeData) {
return node.type === CanvasNodeType.Video && node.metadata?.status === "success" && Boolean(node.metadata.content);
}