From 3278776e7f4b642cc81c201ffcf7b6c97f47b19f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Apr 2026 18:31:31 +0800 Subject: [PATCH] Restore LibTV module action workflows --- .../canvas/LiblibVideoCanvasReplica.tsx | 420 +++++++++++++++++- 1 file changed, 411 insertions(+), 9 deletions(-) diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 840e2aa99..ac21bcfc5 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -190,6 +190,19 @@ type ComposerSettings = { type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script"; type CanvasCreateKind = AddModuleKind; +type TargetModuleActionLabel = + | "自己编写内容" + | "文生视频" + | "图片反推提示词" + | "文字生音乐" + | "图生图" + | "图片高清" + | "音频生视频" + | "首尾帧生成视频" + | "首帧生成视频" + | "脚本生成分镜脚本" + | "视频参考生成分镜脚本" + | "角色生成分镜脚本"; type LiblibVideoCanvasReplicaProps = { canvasId: string; @@ -394,6 +407,31 @@ const CREDIT_PACKS = [ const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; const AUDIO_TO_VIDEO_PROMPT = "根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"; +const TEXT_TO_VIDEO_PROMPT = + "爆款情绪短片\n傍晚的城市天台,一个年轻人迎着晚风张开双手,镜头从远景缓慢推进到中景,头发被风吹起,夕阳逆光,光晕明显,空气中有漂浮微尘,电影级色彩分级,浅景深,情绪张力强,慢动作"; +const TEXT_TO_VIDEO_GENERATE_PROMPT = "根据文字描述生成视频。"; +const TEXT_TO_MUSIC_PROMPT = + "生成一首现代品牌电子音乐,干净有力的低频贝斯,清晰电子鼓点,整体风格高级、未来感强。"; +const IMAGE_TO_PROMPT_PROMPT = "根据图片生成结构化中文提示词,包括主体描述、环境、光影、镜头语言、风格关键词。"; +const HD_IMAGE_SOURCE_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/5d40e509-6182-4d5d-b202-8e130b44a66c.png"; +const VIDEO_FIRST_FRAME_SOURCE_URL = + "https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/604bb8d4-e8d1-439d-80b4-9867a579d0e7.png"; +const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。"; +const VIDEO_FIRST_LAST_FIRST_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/3dbb2a8e-47a7-4480-9de0-293adea8fbb4.png"; +const VIDEO_FIRST_LAST_LAST_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/cea9eb03-e93f-45ab-b1c3-42d749d95321.png"; +const VIDEO_FIRST_LAST_PROMPT = + "手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然"; +const SCRIPT_VIDEO_REFERENCE_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/7a21a9b9-503b-4168-b9c2-be0a3770f215.mp4"; +const SCRIPT_CHARACTER_ONE_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/b6989b27-e725-4958-a062-e8dfb3312db3.png"; +const SCRIPT_CHARACTER_TWO_URL = + "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/fe840bb1-649e-4664-a23d-30f5ecf8e36c.png"; +const SCRIPT_CHARACTER_PROMPT = + "根据角色参考图生成分镜脚本,围绕旧城区、机械修复、暴雨停电和被重新点亮的街道展开,突出人物关系、镜头画面和声音设计。"; const SLASH_IMAGE_ITEMS = [ "剧情推演四宫格", "25宫格连贯分镜", @@ -828,6 +866,89 @@ function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: num }; } +function createMediaResourceNode({ + action, + height, + mediaType, + mediaUrl, + title, + width, + x, + y, + prompt, +}: { + action?: string; + height?: number; + mediaType: NonNullable; + mediaUrl: string; + prompt?: string; + title: string; + width?: number; + x: number; + y: number; +}): CloneNode { + const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : mediaType === "text" ? "文本" : "图片"; + return { + id: makeId(mediaType), + kind: "media", + title, + subtitle: `${label}资源`, + inputType: mediaType, + x, + y, + width: width ?? (mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420), + height: height ?? (mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280), + accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", + draftValues: { + action: action ?? `${mediaType.toUpperCase()}_RESOURCE`, + alt: title, + generated: false, + mediaType, + mediaUrl, + nodeType: mediaType, + params: prompt ? { prompt } : undefined, + prompt: prompt || "", + url: [mediaUrl], + }, + }; +} + +function createTextResourceNode({ + content, + height = 430, + title, + width = 560, + x, + y, +}: { + content: string; + height?: number; + title: string; + width?: number; + x: number; + y: number; +}): CloneNode { + return { + id: makeId("text"), + kind: "script", + title, + subtitle: "文本资源", + inputType: "text", + x, + y, + width, + height, + accent: "#f0d06b", + draftValues: { + action: "TEXT_RESOURCE", + content: [content], + nodeType: "text", + scriptRole: "resource", + viewMode: undefined, + }, + }; +} + function getDraftString(node: CloneNode | null | undefined, key: string) { const value = node?.draftValues?.[key]; return typeof value === "string" ? value : ""; @@ -848,6 +969,9 @@ function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene { function getTargetModuleKind(node: CloneNode): AddModuleKind | null { if (node.kind === "media" || getPresetMode(node)) return null; + if (getDraftString(node, "action") === "TEXT_RESOURCE" || getDraftString(node, "scriptRole") === "resource") { + return null; + } const nodeType = getDraftString(node, "nodeType"); if ( nodeType === "text" || @@ -1347,6 +1471,275 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [addNodes, connectionMenu, nodes] ); + const runTargetModuleAction = useCallback( + (nodeId: string, actionLabel: TargetModuleActionLabel) => { + const anchor = nodes.find((node) => node.id === nodeId); + if (!anchor) return; + + const leftX = Math.round(anchor.x - 560 - 120); + const rightX = Math.round(anchor.x + anchor.width + 120); + const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); + const nextNodes: CloneNode[] = []; + const nextEdges: CloneEdge[] = []; + let selectedId = anchor.id; + let anchorUpdates: Partial = {}; + let draftUpdates: Record = {}; + + const connectToAnchor = (node: CloneNode) => { + nextNodes.push(node); + nextEdges.push({ id: makeId("edge"), source: node.id, target: anchor.id }); + selectedId = node.id; + }; + + const connectFromAnchor = (node: CloneNode) => { + nextNodes.push(node); + nextEdges.push({ id: makeId("edge"), source: anchor.id, target: node.id }); + selectedId = node.id; + }; + + const makeGenerated = (kind: Exclude | "audio", title: string, x: number, updates?: Record) => { + const created = createTargetModuleNode(kind, x, 0, nodes.length + nextNodes.length + 1); + return { + ...created, + title, + y: centerY(created.height), + draftValues: { + ...(created.draftValues || {}), + ...(updates || {}), + }, + }; + }; + + switch (actionLabel) { + case "自己编写内容": { + draftUpdates = { action: "TEXT_RESOURCE", content: [], manualEditing: true }; + toast.success("已切换为文本编写节点"); + break; + } + case "文生视频": { + draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_VIDEO_PROMPT] }; + connectFromAnchor(makeGenerated("video", "视频", rightX, { params: { prompt: TEXT_TO_VIDEO_GENERATE_PROMPT } })); + break; + } + case "图片反推提示词": { + const image = createMediaResourceNode({ + mediaType: "image", + mediaUrl: FIRST_FRAME_IMAGE_URL, + title: "图片", + width: 420, + height: 280, + x: leftX, + y: centerY(280), + }); + draftUpdates = { + action: "TEXT_GENERATE", + content: [], + params: { ...(anchor.draftValues?.params as Record | undefined), prompt: IMAGE_TO_PROMPT_PROMPT }, + }; + connectToAnchor(image); + break; + } + case "文字生音乐": { + draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_MUSIC_PROMPT] }; + connectFromAnchor(makeGenerated("audio", "音频", rightX, { params: { scene: "Music" } })); + break; + } + case "图生图": { + anchorUpdates = { + kind: "media", + title: "图片", + subtitle: "图片资源", + width: 420, + height: 280, + inputType: "image", + }; + draftUpdates = { + action: "IMAGE_RESOURCE", + generated: false, + mediaType: "image", + mediaUrl: CHARACTER_IMAGE_URL, + nodeType: "image", + url: [CHARACTER_IMAGE_URL], + }; + connectFromAnchor(makeGenerated("image", "图片生成", rightX)); + break; + } + case "图片高清": { + anchorUpdates = { + kind: "media", + title: "图片", + subtitle: "图片资源", + width: 420, + height: 280, + inputType: "image", + }; + draftUpdates = { + action: "IMAGE_RESOURCE", + generated: false, + mediaType: "image", + mediaUrl: HD_IMAGE_SOURCE_URL, + nodeType: "image", + url: [HD_IMAGE_SOURCE_URL], + }; + connectFromAnchor(makeGenerated("image", "高清", rightX, { generatorType: "ENHANCE" })); + break; + } + case "音频生视频": { + anchorUpdates = { + kind: "media", + title: "音频", + subtitle: "音频资源", + width: 430, + height: 170, + inputType: "audio", + }; + draftUpdates = { + action: "AUDIO_RESOURCE", + generated: false, + mediaType: "audio", + mediaUrl: AUDIO_SOURCE_URL, + nodeType: "audio", + url: [AUDIO_SOURCE_URL], + }; + const video = makeGenerated("video", "视频", rightX, { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }); + const image = createMediaResourceNode({ + mediaType: "image", + mediaUrl: AUDIO_REFERENCE_IMAGE_URL, + title: "图片", + width: 430, + height: 252, + x: anchor.x, + y: Math.round(anchor.y + 170 + 120), + }); + nextNodes.push(video, image); + nextEdges.push( + { id: makeId("edge"), source: anchor.id, target: video.id }, + { id: makeId("edge"), source: image.id, target: video.id } + ); + selectedId = video.id; + break; + } + case "首帧生成视频": { + draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_FRAME_PROMPT } }; + connectToAnchor( + createMediaResourceNode({ + mediaType: "image", + mediaUrl: VIDEO_FIRST_FRAME_SOURCE_URL, + title: "首帧", + width: 420, + height: 280, + x: leftX, + y: centerY(280), + }) + ); + selectedId = anchor.id; + break; + } + case "首尾帧生成视频": { + draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_LAST_PROMPT } }; + const first = createMediaResourceNode({ + mediaType: "image", + mediaUrl: VIDEO_FIRST_LAST_FIRST_URL, + title: "首帧", + width: 420, + height: 240, + x: leftX, + y: Math.round(anchor.y + 18), + }); + const last = createMediaResourceNode({ + mediaType: "image", + mediaUrl: VIDEO_FIRST_LAST_LAST_URL, + title: "尾帧", + width: 420, + height: 240, + x: leftX, + y: Math.round(anchor.y + 286), + }); + connectToAnchor(first); + connectToAnchor(last); + selectedId = anchor.id; + break; + } + case "脚本生成分镜脚本": { + connectToAnchor(createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftX, y: centerY(430) })); + selectedId = anchor.id; + break; + } + case "视频参考生成分镜脚本": { + draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: "解析视频内容生成分镜脚本。" } }; + connectToAnchor( + createMediaResourceNode({ + action: "VIDEO_RESOURCE", + mediaType: "video", + mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, + prompt: "解析视频内容生成分镜脚本。", + title: "视频", + width: 560, + height: 315, + x: leftX, + y: centerY(315), + }) + ); + selectedId = anchor.id; + break; + } + case "角色生成分镜脚本": { + draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: SCRIPT_CHARACTER_PROMPT } }; + const first = createMediaResourceNode({ + mediaType: "image", + mediaUrl: SCRIPT_CHARACTER_ONE_URL, + title: "角色 1", + width: 300, + height: 300, + x: leftX, + y: Math.round(anchor.y + 10), + }); + const second = createMediaResourceNode({ + mediaType: "image", + mediaUrl: SCRIPT_CHARACTER_TWO_URL, + title: "角色 2", + width: 300, + height: 300, + x: leftX, + y: Math.round(anchor.y + 330), + }); + connectToAnchor(first); + connectToAnchor(second); + selectedId = anchor.id; + break; + } + default: + break; + } + + setNodes((current) => [ + ...current.map((item) => + item.id === anchor.id + ? { + ...item, + ...anchorUpdates, + draftValues: { + ...(item.draftValues || {}), + ...draftUpdates, + }, + } + : item + ), + ...nextNodes, + ]); + if (nextEdges.length > 0) { + setEdges((current) => [...current, ...nextEdges]); + } + setSelectedNodeId(selectedId); + setNodeContextMenu(null); + setConnectionMenu(null); + setCanvasCommand(null); + setActivePanel(null); + if (actionLabel !== "自己编写内容") toast.success(`已按「${actionLabel}」创建关联节点`); + }, + [nodes] + ); + const createFreeNode = useCallback( (kind: CanvasCreateKind) => { if (!canvasCommand) return; @@ -1638,6 +2031,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib node={node} selected={node.id === selectedNodeId} onContextMenu={(event) => openNodeContext(event, node)} + onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onPointerDown={(event) => startNodeDrag(event, node)} /> @@ -3565,12 +3959,14 @@ function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] } function CloneCanvasNode({ node, onContextMenu, + onModuleAction, onOpenConnectionMenu, onPointerDown, selected, }: { node: CloneNode; onContextMenu: (event: ReactMouseEvent) => void; + onModuleAction: (actionLabel: TargetModuleActionLabel) => void; onOpenConnectionMenu: (event: ReactMouseEvent, side: ConnectionSide) => void; onPointerDown: (event: ReactPointerEvent) => void; selected: boolean; @@ -3579,7 +3975,7 @@ function CloneCanvasNode({ if (targetModuleKind) { return ( - + ); } @@ -3662,14 +4058,16 @@ function CloneCanvasNode({ function TargetModuleCanvasNode({ kind, node, + onAction, selected, }: { kind: AddModuleKind; node: CloneNode; + onAction: (actionLabel: TargetModuleActionLabel) => void; selected: boolean; }) { const HeaderIcon = kind === "video-clip" ? Scissors : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText; - const actions: Record> = { + const actions: Record> = { audio: [{ icon: Play, label: "音频生视频" }], image: [ { icon: Upload, label: "图生图" }, @@ -3728,7 +4126,7 @@ function TargetModuleCanvasNode({