From cfd48b6b3be63aa423fd4da6c0d8b4491da3990e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Apr 2026 15:49:06 +0800 Subject: [PATCH] Load LibTV starter workflows on canvas --- .../canvas/LiblibVideoCanvasReplica.tsx | 673 ++++++++++++++++-- 1 file changed, 607 insertions(+), 66 deletions(-) diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 2d90c61e4..fcc3fcf63 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -31,6 +31,7 @@ import { useRouter } from "next/navigation"; import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -105,7 +106,7 @@ type WorkflowPreset = { cover?: string; }; -type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save"; +type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "script"; type CloneNode = { id: string; @@ -614,6 +615,242 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb }; } +function getDraftString(node: CloneNode | null | undefined, key: string) { + const value = node?.draftValues?.[key]; + return typeof value === "string" ? value : ""; +} + +function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene { + const value = getDraftString(node, "targetPreset"); + if ( + value === "story-script" || + value === "character-three-view" || + value === "first-frame" || + value === "audio-to-video" + ) { + return value; + } + return null; +} + +function createPresetMediaNode({ + action, + generated = false, + height, + mediaType, + mediaUrl, + preset, + prompt, + title, + width, + x, + y, +}: { + action: string; + generated?: boolean; + height: number; + mediaType: NonNullable; + mediaUrl?: string; + preset: Exclude; + prompt?: string; + title: string; + width: number; + x: number; + y: number; +}): CloneNode { + return { + id: makeId(mediaType), + kind: "media", + title, + subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源", + inputType: mediaType, + x, + y, + width, + height, + accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", + draftValues: { + action, + generated, + mediaType, + mediaUrl: mediaUrl || "", + params: prompt ? { prompt } : undefined, + prompt: prompt || "", + targetPreset: preset, + url: mediaUrl ? [mediaUrl] : [], + }, + }; +} + +function createPresetScriptNode({ + preset, + role, + title, + x, + y, +}: { + preset: Exclude; + role: "resource" | "generator"; + title: string; + x: number; + y: number; +}): CloneNode { + const isGenerator = role === "generator"; + return { + id: makeId(isGenerator ? "script" : "text"), + kind: "script", + title, + subtitle: isGenerator ? "脚本生成器" : "文本资源", + inputType: "text", + x, + y, + width: 560, + height: 430, + accent: "#f0d06b", + draftValues: { + action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE", + content: isGenerator ? [] : [STORY_SCRIPT_TEXT], + params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined, + prompt: isGenerator ? STORY_SCRIPT_PROMPT : "", + rows: isGenerator ? [] : undefined, + scriptRole: role, + targetPreset: preset, + viewMode: isGenerator ? "table" : undefined, + }, + }; +} + +function makePresetEdges(leftNodes: CloneNode[], rightNode: CloneNode): CloneEdge[] { + return leftNodes.map((node) => ({ + id: makeId("edge"), + source: node.id, + target: rightNode.id, + })); +} + +function createStarterWorkflow( + starterTitle: string, + center: { x: number; y: number } +): { nodes: CloneNode[]; edges: CloneEdge[]; selectedId: string } | null { + if (starterTitle === "故事脚本生成") { + const preset = "story-script"; + const left = createPresetScriptNode({ + preset, + role: "resource", + title: "剧本", + x: Math.round(center.x - 610), + y: Math.round(center.y - 320), + }); + const right = createPresetScriptNode({ + preset, + role: "generator", + title: "脚本生成器", + x: Math.round(center.x + 50), + y: Math.round(center.y - 320), + }); + return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id }; + } + + if (starterTitle === "角色三视图") { + const preset = "character-three-view"; + const distance = 370; + const left = createPresetMediaNode({ + action: "IMAGE_RESOURCE", + height: 446, + mediaType: "image", + mediaUrl: CHARACTER_IMAGE_URL, + preset, + title: "角色图", + width: 734, + x: Math.round(center.x - 734 - distance / 2), + y: Math.round(center.y - 320), + }); + const right = createPresetMediaNode({ + action: "IMAGE_GENERATE", + generated: true, + height: 446, + mediaType: "image", + mediaUrl: CHARACTER_THREE_VIEW_URL, + preset, + title: "角色三视图", + width: 556, + x: Math.round(center.x + distance / 2), + y: Math.round(center.y - 320), + }); + return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id }; + } + + if (starterTitle === "首帧图生视频") { + const preset = "first-frame"; + const left = createPresetMediaNode({ + action: "IMAGE_RESOURCE", + height: 430, + mediaType: "image", + mediaUrl: FIRST_FRAME_IMAGE_URL, + preset, + title: "首帧", + width: 620, + x: Math.round(center.x - 675), + y: Math.round(center.y - 310), + }); + const right = createPresetMediaNode({ + action: "VIDEO_GENERATE", + generated: true, + height: 430, + mediaType: "video", + preset, + title: "视频", + width: 560, + x: Math.round(center.x + 55), + y: Math.round(center.y - 310), + }); + return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id }; + } + + if (starterTitle === "音频生视频") { + const preset = "audio-to-video"; + const leftX = Math.round(center.x - 510); + const leftY = Math.round(center.y - 320); + const audio = createPresetMediaNode({ + action: "AUDIO_RESOURCE", + height: 158, + mediaType: "audio", + mediaUrl: AUDIO_SOURCE_URL, + preset, + title: "音频", + width: 430, + x: leftX, + y: leftY, + }); + const image = createPresetMediaNode({ + action: "IMAGE_RESOURCE", + height: 252, + mediaType: "image", + mediaUrl: AUDIO_REFERENCE_IMAGE_URL, + preset, + title: "图片", + width: 430, + x: leftX, + y: leftY + 190, + }); + const right = createPresetMediaNode({ + action: "VIDEO_GENERATE", + generated: true, + height: 430, + mediaType: "video", + preset, + prompt: AUDIO_TO_VIDEO_PROMPT, + title: "视频", + width: 560, + x: Math.round(center.x + 100), + y: Math.round(center.y - 320), + }); + return { nodes: [audio, image, right], edges: makePresetEdges([audio, image], right), selectedId: right.id }; + } + + return null; +} + export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) { const router = useRouter(); const canvasRef = useRef(null); @@ -626,7 +863,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); const [activePanel, setActivePanel] = useState(null); - const [activeStarterScene, setActiveStarterScene] = useState(null); const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部"); const [toolSearch, setToolSearch] = useState(""); const [isProjectLoading, setIsProjectLoading] = useState(true); @@ -833,7 +1069,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib : createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY) ); addNodes([node], []); - setActiveStarterScene(null); setCanvasCommand(null); toast.success(`已添加 ${node.title}`); }, @@ -886,23 +1121,21 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const createStarter = useCallback( (starter: (typeof STARTER_CARDS)[number]) => { - const starterScenes: Record> = { - 故事脚本生成: "story-script", - 角色三视图: "character-three-view", - 首帧图生视频: "first-frame", - 音频生视频: "audio-to-video", - }; - const starterScene = starterScenes[starter.title]; - if (starterScene) { - setNodes([]); - setEdges([]); - setSelectedNodeId(null); + const center = + typeof window === "undefined" + ? { x: 1000, y: 520 } + : toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); + const workflow = createStarterWorkflow(starter.title, center); + if (workflow) { + addNodes(workflow.nodes, workflow.edges); + setSelectedNodeId(workflow.selectedId); + setCanvasCommand(null); + setNodeContextMenu(null); setActivePanel(null); - setActiveStarterScene(starterScene); + toast.success(`已载入 ${starter.title} 工作流`); return; } - setActiveStarterScene(null); const baseX = 380; const baseY = 325; const newNodes = starter.steps.map((title, index) => { @@ -923,7 +1156,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib })); addNodes(newNodes, newEdges); }, - [addNodes] + [addNodes, toCanvasPoint] ); const updateNodeDraft = useCallback((key: string, value: unknown) => { @@ -971,7 +1204,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.closest("input, textarea, select, [contenteditable='true']")) return; - if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId && !activeStarterScene) { + if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId) { event.preventDefault(); deleteNode(selectedNodeId); } @@ -982,7 +1215,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [activeStarterScene, deleteNode, selectedNodeId]); + }, [deleteNode, selectedNodeId]); const onPointerMove = useCallback( (event: ReactPointerEvent) => { @@ -1037,6 +1270,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib }, []); const isBusy = isLoading || isProjectLoading; + const selectedPresetMode = getPresetMode(selectedNode); return (
- {activeStarterScene === "story-script" ? : null} - {activeStarterScene === "character-three-view" ? : null} - {activeStarterScene === "first-frame" ? : null} - {activeStarterScene === "audio-to-video" ? : null} - - {nodes.length === 0 && !activePanel && !activeStarterScene ? ( + {nodes.length === 0 && !activePanel ? (
) : null} - {!activeStarterScene ? ( - <> - toggleTopPanel("account")} - onBell={() => toggleTopPanel("notifications")} - onCommitTitle={commitTitle} - onDraftTitleChange={setDraftTitle} - onMember={() => toggleTopPanel("member")} - onSave={saveCanvas} - onShare={() => toggleTopPanel("share")} - onSkills={() => toggleTopPanel("skills")} - onStartTitleEdit={() => setIsTitleEditing(true)} - projectTitle={projectTitle} - routerBack={() => router.push("/")} - /> + {selectedPresetMode ? : null} - setActivePanel((current) => current === panel ? null : panel)} /> - - ) : null} + toggleTopPanel("account")} + onBell={() => toggleTopPanel("notifications")} + onCommitTitle={commitTitle} + onDraftTitleChange={setDraftTitle} + onMember={() => toggleTopPanel("member")} + onSave={saveCanvas} + onShare={() => toggleTopPanel("share")} + onSkills={() => toggleTopPanel("skills")} + onStartTitleEdit={() => setIsTitleEditing(true)} + projectTitle={projectTitle} + routerBack={() => router.push("/")} + /> + + setActivePanel((current) => current === panel ? null : panel)} /> setViewport(DEFAULT_VIEWPORT)} onZoomIn={() => zoomBy(0.1)} @@ -1190,7 +1417,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib /> ) : null} - {selectedNode ? ( + {selectedNode && !selectedPresetMode ? ( }) { + if (mode === "character-three-view") { + return ( +
+ + +
+ ); + } + + if (mode === "story-script") { + return ( +
+ + +
+ ); + } + + if (mode === "first-frame") { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +} + +function CharacterPresetToolbar() { + const [toolbarMenu, setToolbarMenu] = useState(null); + + return ( + <> +
+ setToolbarMenu((current) => current === "panorama" ? null : "panorama")} /> + setToolbarMenu((current) => current === "orbit" ? null : "orbit")} /> + setToolbarMenu((current) => current === "lighting" ? null : "lighting")} /> + setToolbarMenu((current) => current === "grid" ? null : "grid")} /> + setToolbarMenu((current) => current === "hd" ? null : "hd")} /> + setToolbarMenu((current) => current === "crop" ? null : "crop")} /> +
+ {["⌁", "↔", "↓", "↗"].map((item, index) => ( + + ))} +
+ {toolbarMenu ? setToolbarMenu(null)} /> : null} + + ); +} + function StoryScriptScene() { return (
@@ -2546,35 +2876,47 @@ function CloneCanvasNode({ onPointerDown: (event: ReactPointerEvent) => void; selected: boolean; }) { + if (node.kind === "media") { + return ( + + + + ); + } + + if (node.kind === "script") { + return ( + + + + ); + } + const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon; const isInput = node.kind === "input"; const isWarm = node.kind === "text" || node.kind === "audio"; + const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); return ( -
- - + {isInput ? (
- + {previewUrl ? ( + <> + + + + ) : null} + -
{node.title}
-
{node.subtitle}
-
@@ -2597,13 +2939,212 @@ function CloneCanvasNode({
{node.subtitle || "工具"}
-
{node.title}
+
{node.title}
待
配置
)} + + ); +} + +function CanvasNodeShell({ + children, + node, + onContextMenu, + onPointerDown, + selected, +}: { + children: ReactNode; + node: CloneNode; + onContextMenu: (event: ReactMouseEvent) => void; + onPointerDown: (event: ReactPointerEvent) => void; + selected: boolean; +}) { + return ( +
+ + + {children} +
+ ); +} + +function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) { + const mediaType = getDraftString(node, "mediaType") || node.inputType || "image"; + const mediaUrl = getDraftString(node, "mediaUrl"); + const generated = Boolean(node.draftValues?.generated); + const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon; + + return ( +
+
+ + {node.title} +
+ {mediaType === "audio" ? ( +
+
+ +
+
情绪配乐.mp3
+
音频生视频参考素材
+
+ {mediaUrl ? ( + + 试听 + + ) : null} +
+
+ {Array.from({ length: 38 }).map((_, index) => ( + + ))} +
+
+ ) : mediaType === "video" ? ( +
+
+
+
+
+
待生成视频
+
生成后将在这里预览
+
+ +
+ ) : ( +
+ {mediaUrl ? ( + {node.title} + ) : ( +
等待图片
+ )} + {!generated ? ( + + ) : null} +
+ )} +
+ ); +} + +function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) { + const scriptRole = getDraftString(node, "scriptRole"); + const rows = [ + ["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"], + ["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"], + ["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"], + ]; + + return ( +
+
+ {scriptRole === "generator" ? : } + {node.title} +
+ {scriptRole === "generator" ? ( +
+
+ 故事脚本 + 待生成 +
+
+ {["镜号", "场景", "画面", "声音"].map((item) => ( +
{item}
+ ))} +
+ {rows.map((row) => ( +
+ {row.map((cell) => ( +
{cell}
+ ))} +
+ ))} + +
+ ) : ( +
+
+ 输入内容 + +
+
+ {STORY_SCRIPT_TEXT} +
+
+ )}
); }