Your Name e054dcabcc
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: update canvas and flow board features
2026-04-11 22:28:18 +08:00

762 lines
20 KiB
TypeScript

import type { CanvasNode, NodeType, Position } from "@/types/canvas";
const DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR = "#111827";
export type VideoWorkflowSectionId =
| "input"
| "analysis"
| "storyboard"
| "generation"
| "character"
| "tool"
| "output";
export type VideoWorkflowTemplateType =
| "camera-movement"
| "canvas-node"
| "character-description"
| "create-character"
| "create-scene"
| "custom-agent"
| "doodle-canvas"
| "extract-characters-scenes"
| "gen-image"
| "gen-music"
| "gen-video"
| "generate-character-image"
| "generate-character-video"
| "generate-scene-image"
| "generate-scene-video"
| "image-compare"
| "inpaint-crop"
| "inpaint-stitch"
| "input-image"
| "jimeng-super-resolution"
| "local-save"
| "motion-control"
| "novel-input"
| "preview"
| "professional-camera"
| "rh-app"
| "rh-comfy"
| "scene-description"
| "storyboard-chart-node"
| "storyboard-node"
| "table-editor-node"
| "text-node"
| "video-analyze"
| "video-input";
export interface VideoWorkflowQuickConnectOption {
type: VideoWorkflowTemplateType;
label: string;
}
export interface VideoWorkflowNodeDefinition {
type: VideoWorkflowTemplateType;
label: string;
description: string;
group: VideoWorkflowSectionId;
size: {
width: number;
height: number;
};
canvasNodeType: NodeType;
accentColor: string;
quickConnectOutputs: VideoWorkflowQuickConnectOption[];
}
export interface VideoWorkflowSectionDefinition {
id: VideoWorkflowSectionId;
label: string;
description: string;
}
export interface VideoWorkflowStarterTemplate {
id: string;
title: string;
description: string;
nodes: VideoWorkflowTemplateType[];
}
const QUICK_CONNECT_CONFIG: Partial<
Record<VideoWorkflowTemplateType, VideoWorkflowQuickConnectOption[]>
> = {
"text-node": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
{ type: "custom-agent", label: "Agent" },
],
"input-image": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
{ type: "storyboard-node", label: "分镜视表" },
{ type: "inpaint-crop", label: "裁剪局部重绘" },
],
"novel-input": [
{ type: "custom-agent", label: "Agent" },
{ type: "text-node", label: "文本" },
{ type: "rh-app", label: "RH 应用" },
],
"video-input": [
{ type: "preview", label: "预览" },
{ type: "rh-app", label: "RH 应用" },
],
"video-analyze": [
{ type: "storyboard-node", label: "分镜视表" },
{ type: "gen-image", label: "绘画" },
],
"table-editor-node": [{ type: "storyboard-node", label: "分镜视表" }],
"storyboard-node": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
],
"storyboard-chart-node": [{ type: "preview", label: "预览" }],
"inpaint-stitch": [
{ type: "preview", label: "预览" },
{ type: "local-save", label: "保存" },
],
};
const SECTION_DEFINITIONS: VideoWorkflowSectionDefinition[] = [
{ id: "input", label: "输入", description: "脚本、图片、视频和小说素材入口" },
{ id: "analysis", label: "分析", description: "拆解素材、抽取镜头和角色语义" },
{ id: "storyboard", label: "分镜", description: "组织镜头表、图表和镜头语言" },
{ id: "generation", label: "生成", description: "图片、视频、音乐和增强节点" },
{ id: "character", label: "角色场景", description: "角色与场景资产沉淀和派生" },
{ id: "tool", label: "工具", description: "代理、画板、局部编辑和外部应用" },
{ id: "output", label: "输出", description: "预览、对比和保存结果" },
];
const COMPACT_NODE_SIZE_OVERRIDES: Partial<
Record<VideoWorkflowTemplateType, { width: number; height: number }>
> = {
"text-node": { width: 300, height: 170 },
"novel-input": { width: 340, height: 240 },
"input-image": { width: 220, height: 220 },
"video-input": { width: 240, height: 220 },
"video-analyze": { width: 300, height: 210 },
"extract-characters-scenes": { width: 300, height: 210 },
"storyboard-node": { width: 380, height: 240 },
"storyboard-chart-node": { width: 360, height: 220 },
"table-editor-node": { width: 340, height: 220 },
"gen-image": { width: 280, height: 190 },
"gen-video": { width: 280, height: 190 },
"gen-music": { width: 300, height: 200 },
"jimeng-super-resolution": { width: 260, height: 160 },
"motion-control": { width: 300, height: 220 },
"inpaint-crop": { width: 280, height: 180 },
"inpaint-stitch": { width: 280, height: 180 },
"professional-camera": { width: 280, height: 180 },
"camera-movement": { width: 280, height: 180 },
"image-compare": { width: 320, height: 220 },
preview: { width: 320, height: 220 },
"local-save": { width: 260, height: 180 },
"character-description": { width: 300, height: 180 },
"scene-description": { width: 300, height: 180 },
"create-character": { width: 280, height: 160 },
"create-scene": { width: 280, height: 160 },
"generate-character-image": { width: 300, height: 200 },
"generate-character-video": { width: 300, height: 200 },
"generate-scene-image": { width: 300, height: 200 },
"generate-scene-video": { width: 300, height: 200 },
"custom-agent": { width: 340, height: 220 },
"rh-app": { width: 280, height: 180 },
"rh-comfy": { width: 280, height: 180 },
"canvas-node": { width: 360, height: 240 },
"doodle-canvas": { width: 300, height: 180 },
};
function resolveCompactNodeSize(
type: VideoWorkflowTemplateType,
canvasNodeType: NodeType,
size: { width: number; height: number }
) {
const override = COMPACT_NODE_SIZE_OVERRIDES[type];
if (override) {
return override;
}
if (canvasNodeType === "workflow") {
return {
width: Math.min(size.width, 300),
height: Math.min(size.height, 180),
};
}
if (canvasNodeType === "text") {
return {
width: Math.min(size.width, 320),
height: Math.min(size.height, 180),
};
}
if (canvasNodeType === "image") {
return {
width: Math.min(size.width, 220),
height: Math.min(size.height, 220),
};
}
if (canvasNodeType === "video") {
return {
width: Math.min(size.width, 240),
height: Math.min(size.height, 220),
};
}
return size;
}
const createDefinition = (
type: VideoWorkflowTemplateType,
label: string,
description: string,
group: VideoWorkflowSectionId,
size: { width: number; height: number },
canvasNodeType: NodeType,
accentColor: string
): VideoWorkflowNodeDefinition => ({
type,
label,
description,
group,
size: resolveCompactNodeSize(type, canvasNodeType, size),
canvasNodeType,
accentColor,
quickConnectOutputs: QUICK_CONNECT_CONFIG[type] || [],
});
export const VIDEO_WORKFLOW_NODE_DEFINITIONS: Record<
VideoWorkflowTemplateType,
VideoWorkflowNodeDefinition
> = {
"camera-movement": createDefinition(
"camera-movement",
"镜头运动",
"规划推拉摇移、运镜节奏和镜头情绪。",
"storyboard",
{ width: 340, height: 480 },
"workflow",
"#fde68a"
),
"canvas-node": createDefinition(
"canvas-node",
"画板节点",
"把多个素材汇总到一个创意画板里继续编排。",
"tool",
{ width: 600, height: 500 },
"workflow",
"#e2e8f0"
),
"character-description": createDefinition(
"character-description",
"角色描述",
"沉淀角色外观、气质和动作特征。",
"character",
{ width: 400, height: 400 },
"workflow",
"#dbeafe"
),
"create-character": createDefinition(
"create-character",
"创建角色",
"将角色设定固化成可复用的角色资产。",
"character",
{ width: 350, height: 300 },
"workflow",
"#dbeafe"
),
"create-scene": createDefinition(
"create-scene",
"创建场景",
"将场景描述固化成可复用的世界观素材。",
"character",
{ width: 350, height: 300 },
"workflow",
"#dbeafe"
),
"custom-agent": createDefinition(
"custom-agent",
"自定义代理",
"接入一段更复杂的代理链路处理创作任务。",
"tool",
{ width: 500, height: 800 },
"workflow",
"#e2e8f0"
),
"doodle-canvas": createDefinition(
"doodle-canvas",
"涂鸦画板",
"用草图、路径或遮罩表达额外视觉意图。",
"tool",
{ width: 500, height: 300 },
"workflow",
"#e2e8f0"
),
"extract-characters-scenes": createDefinition(
"extract-characters-scenes",
"提取角色场景",
"从长文本里拆出角色、地点和关系线索。",
"analysis",
{ width: 400, height: 500 },
"workflow",
"#bbf7d0"
),
"gen-image": createDefinition(
"gen-image",
"生成图片",
"根据上游脚本、分镜或素材生成关键帧。",
"generation",
{ width: 360, height: 340 },
"workflow",
"#fecaca"
),
"gen-music": createDefinition(
"gen-music",
"生成音乐",
"为视频节奏匹配音乐、氛围和情绪。",
"generation",
{ width: 350, height: 700 },
"workflow",
"#fecaca"
),
"gen-video": createDefinition(
"gen-video",
"生成视频",
"根据文本、图像或分镜节点生成视频镜头。",
"generation",
{ width: 360, height: 340 },
"workflow",
"#fecaca"
),
"generate-character-image": createDefinition(
"generate-character-image",
"生成角色图片",
"围绕角色设定输出稳定的角色图像。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-character-video": createDefinition(
"generate-character-video",
"生成角色视频",
"围绕角色设定输出角色镜头或动作片段。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-scene-image": createDefinition(
"generate-scene-image",
"生成场景图片",
"围绕场景设定生成环境和氛围关键帧。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-scene-video": createDefinition(
"generate-scene-video",
"生成场景视频",
"围绕场景设定生成世界观或转场镜头。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"image-compare": createDefinition(
"image-compare",
"图片对比",
"并排比较不同版本画面与细节变化。",
"output",
{ width: 400, height: 300 },
"workflow",
"#bae6fd"
),
"inpaint-crop": createDefinition(
"inpaint-crop",
"裁剪局部重绘",
"选定区域后做局部改图、补图或定点修改。",
"tool",
{ width: 340, height: 380 },
"workflow",
"#e2e8f0"
),
"inpaint-stitch": createDefinition(
"inpaint-stitch",
"无缝拼回",
"把局部重绘结果回贴到原始画面里。",
"tool",
{ width: 340, height: 380 },
"workflow",
"#e2e8f0"
),
"input-image": createDefinition(
"input-image",
"图片输入",
"上传参考图、角色图、首帧或关键帧素材。",
"input",
{ width: 260, height: 260 },
"image",
"#bfdbfe"
),
"jimeng-super-resolution": createDefinition(
"jimeng-super-resolution",
"智能超清",
"对结果做分辨率增强和画质修复。",
"generation",
{ width: 320, height: 280 },
"workflow",
"#fecaca"
),
"local-save": createDefinition(
"local-save",
"本地保存",
"将流程输出整理并保存到本地项目目录。",
"output",
{ width: 320, height: 380 },
"workflow",
"#bae6fd"
),
"motion-control": createDefinition(
"motion-control",
"动作迁移",
"把动作参考迁移到主体或镜头中。",
"generation",
{ width: 320, height: 580 },
"workflow",
"#fecaca"
),
"novel-input": createDefinition(
"novel-input",
"小说输入",
"粘贴长篇设定、故事梗概或分章节剧情。",
"input",
{ width: 400, height: 500 },
"text",
"#bfdbfe"
),
"preview": createDefinition(
"preview",
"预览节点",
"检查上游视频、图片或分镜输出效果。",
"output",
{ width: 440, height: 310 },
"workflow",
"#bae6fd"
),
"professional-camera": createDefinition(
"professional-camera",
"专业镜头",
"补充焦段、景别和镜头语言规则。",
"storyboard",
{ width: 320, height: 420 },
"workflow",
"#fde68a"
),
"rh-app": createDefinition(
"rh-app",
"RH 应用",
"跳转外部 RH 应用执行专门流程。",
"tool",
{ width: 340, height: 420 },
"workflow",
"#e2e8f0"
),
"rh-comfy": createDefinition(
"rh-comfy",
"RH Comfy",
"挂接 Comfy 风格的外部工作流节点。",
"tool",
{ width: 340, height: 420 },
"workflow",
"#e2e8f0"
),
"scene-description": createDefinition(
"scene-description",
"场景描述",
"沉淀空间氛围、材质和布光信息。",
"character",
{ width: 400, height: 400 },
"workflow",
"#dbeafe"
),
"storyboard-chart-node": createDefinition(
"storyboard-chart-node",
"分镜图表",
"把分镜节点转成更适合检查的图表视图。",
"storyboard",
{ width: 540, height: 480 },
"workflow",
"#fde68a"
),
"storyboard-node": createDefinition(
"storyboard-node",
"分镜节点",
"组织镜头表、时长、画面意图和镜头顺序。",
"storyboard",
{ width: 600, height: 500 },
"workflow",
"#fde68a"
),
"table-editor-node": createDefinition(
"table-editor-node",
"表格编辑器",
"先用结构化表格整理分镜字段再输出。",
"storyboard",
{ width: 520, height: 420 },
"workflow",
"#fde68a"
),
"text-node": createDefinition(
"text-node",
"文本节点",
"输入脚本、镜头、旁白和视频需求。",
"input",
{ width: 360, height: 280 },
"text",
"#bfdbfe"
),
"video-analyze": createDefinition(
"video-analyze",
"视频分析",
"解析视频节奏、镜头结构和可复用画面信息。",
"analysis",
{ width: 400, height: 500 },
"workflow",
"#bbf7d0"
),
"video-input": createDefinition(
"video-input",
"视频输入",
"导入素材视频、参考视频或上游成片。",
"input",
{ width: 360, height: 420 },
"video",
"#bfdbfe"
),
};
export const VIDEO_WORKFLOW_LIBRARY_SECTIONS = SECTION_DEFINITIONS;
export const VIDEO_WORKFLOW_STARTER_TEMPLATES: VideoWorkflowStarterTemplate[] = [
{
id: "script-to-video",
title: "脚本成片",
description: "从脚本文案进入,经过分镜和视频生成,最后预览并保存。",
nodes: ["text-node", "storyboard-node", "gen-video", "preview", "local-save"],
},
{
id: "image-to-video",
title: "图片转视频",
description: "从参考图启动视频生成,适合首帧、角色图和海报成片。",
nodes: ["input-image", "gen-video", "preview", "local-save"],
},
{
id: "video-analysis-remix",
title: "视频拆解再创作",
description: "先解析已有视频,再回到分镜和生图/生视频链路。",
nodes: ["video-input", "video-analyze", "storyboard-node", "gen-video", "preview"],
},
{
id: "novel-character-pipeline",
title: "小说角色拆解",
description: "从长文本提取角色与场景,再衍生角色视频。",
nodes: [
"novel-input",
"extract-characters-scenes",
"create-character",
"generate-character-video",
"preview",
],
},
{
id: "inpaint-repair",
title: "局部重绘回贴",
description: "对参考图局部修改后拼回,再预览和保存。",
nodes: ["input-image", "inpaint-crop", "inpaint-stitch", "preview", "local-save"],
},
];
export function getVideoWorkflowNodeDefinition(
type: VideoWorkflowTemplateType | string | null | undefined
) {
if (!type) return null;
return VIDEO_WORKFLOW_NODE_DEFINITIONS[type as VideoWorkflowTemplateType] || null;
}
export function getVideoWorkflowSectionNodes(sectionId: VideoWorkflowSectionId) {
return Object.values(VIDEO_WORKFLOW_NODE_DEFINITIONS).filter(
(definition) => definition.group === sectionId
);
}
export function getVideoWorkflowQuickConnectOutputs(type: VideoWorkflowTemplateType) {
return VIDEO_WORKFLOW_NODE_DEFINITIONS[type]?.quickConnectOutputs || [];
}
export function inferVideoWorkflowTemplateType(
node: CanvasNode | null | undefined
): VideoWorkflowTemplateType | null {
if (!node) return null;
const rawTemplateType =
typeof (node.data as { templateType?: string; metadata?: Record<string, unknown> })?.templateType ===
"string"
? String((node.data as { templateType?: string }).templateType).trim()
: typeof (node.data as { metadata?: Record<string, unknown> })?.metadata?.templateType ===
"string"
? String((node.data as { metadata?: Record<string, unknown> }).metadata?.templateType).trim()
: "";
if (rawTemplateType && rawTemplateType in VIDEO_WORKFLOW_NODE_DEFINITIONS) {
return rawTemplateType as VideoWorkflowTemplateType;
}
if (node.type === "text") {
return "text-node";
}
if (node.type === "image") {
return "input-image";
}
if (node.type === "video") {
return "video-input";
}
return null;
}
export function buildVideoWorkflowCanvasNode(
type: VideoWorkflowTemplateType,
position: Position
): Omit<CanvasNode, "id" | "createdAt" | "updatedAt"> {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) {
throw new Error(`Unknown video workflow node type: ${type}`);
}
const metadata = {
templateType: definition.type,
label: definition.label,
description: definition.description,
};
if (definition.canvasNodeType === "text") {
const isNovelInput = type === "novel-input";
return {
type: "text",
position,
size: definition.size,
data: {
content: isNovelInput
? "在这里粘贴长篇剧情、人物关系、世界观设定或章节梗概"
: "输入脚本、镜头、画面意图或视频需求",
fontSize: isNovelInput ? 16 : 15,
color: DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR,
lineHeight: 1.5,
borderWidth: 1,
borderColor: "#d4d4d8",
borderRadius: 20,
padding: 18,
backgroundColor: "#ffffff",
metadata,
},
connections: [],
};
}
if (definition.canvasNodeType === "image") {
return {
type: "image",
position,
size: definition.size,
data: {
src: "",
status: "pending",
title: definition.label,
prompt: definition.description,
note: "上传参考图、角色图、海报或关键帧",
metadata,
},
connections: [],
};
}
if (definition.canvasNodeType === "video") {
return {
type: "video",
position,
size: definition.size,
data: {
src: "",
status: "pending",
prompt: "上传视频素材、参考视频,或作为上游输出接收节点",
metadata,
},
connections: [],
};
}
return {
type: "workflow",
position,
size: definition.size,
data: {
templateType: definition.type,
title: definition.label,
description: definition.description,
summary:
definition.quickConnectOutputs.length > 0
? `可接:${definition.quickConnectOutputs
.map((item) => item.label)
.join(" / ")}`
: "当前节点用于处理中间结果或输出检查。",
status: "pending",
group: definition.group,
accentColor: definition.accentColor,
metadata,
},
connections: [],
};
}
export function buildVideoWorkflowStarterTemplateGraph(
templateId: string,
origin: Position
) {
const template =
VIDEO_WORKFLOW_STARTER_TEMPLATES.find((item) => item.id === templateId) ||
VIDEO_WORKFLOW_STARTER_TEMPLATES[0];
const laneHeight = 320;
let cursorX = origin.x;
const nodes = template.nodes.map((type) => {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) {
throw new Error(`Unknown video workflow starter node type: ${type}`);
}
const position = {
x: cursorX,
y: origin.y + Math.max(0, (laneHeight - definition.size.height) / 2),
};
cursorX += definition.size.width + 96;
return {
type,
node: buildVideoWorkflowCanvasNode(type, position),
};
});
const edges = nodes.slice(1).map((node, index) => ({
sourceIndex: index,
targetIndex: index + 1,
type: "default" as const,
}));
return {
template,
nodes,
edges,
};
}