"use client"; import { Bell, ChevronDown, Clock3, Copy, FileText, GitBranch, Headphones, HelpCircle, Image as ImageIcon, Layers, Loader2, Music2, Plus, Play, Save, Scissors, Settings2, Share2, Sparkles, Trash2, Upload, UserRound, Video, X, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { type ChangeEvent as ReactChangeEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json"; import { cn } from "@/lib/utils"; import { canvasService } from "@/services/canvas"; type ActivePanel = | "add" | "workflow" | "assets" | "history" | "help" | "support" | "skills" | "share" | "publish" | "member" | "notifications" | "account" | null; type ActiveStarterScene = "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | null; type SceneToolbarMenu = "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | null; type ComposerMenu = | "style" | "mark" | "focus" | "model" | "ratio" | "resolution" | "camera" | "panorama" | "translate" | "settings" | "count" | "reference" | "slash" | null; type MemberStoreTab = "vip" | "model" | "credits"; type CapturedTool = { type?: string; toolKey?: string; modelKey?: string; modelName?: string; vendor?: string; scene?: string; modes?: string[]; settings?: string[]; advancedSettings?: string[]; propertyKeys?: string[]; generateTypes?: unknown; rules?: unknown; metadata?: { properties?: Record; config?: Record; generateTypes?: unknown; rules?: unknown; [key: string]: unknown; }; }; type WorkflowPreset = { id?: string | number; uuid?: string; name?: string; title?: string; description?: string; cover?: string; }; type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "script"; type CloneNode = { id: string; kind: CloneNodeKind; title: string; subtitle?: string; x: number; y: number; width: number; height: number; accent: string; tool?: CapturedTool; workflow?: WorkflowPreset; inputType?: "image" | "video" | "audio" | "text"; draftValues?: Record; }; type CloneEdge = { id: string; source: string; target: string; }; type DragState = | { kind: "node"; id: string; startX: number; startY: number; nodeX: number; nodeY: number; } | { kind: "canvas"; startX: number; startY: number; viewportX: number; viewportY: number; }; type CanvasCommandState = { canvasX: number; canvasY: number; screenX: number; screenY: number; } | null; type NodeContextState = { nodeId: string; screenX: number; screenY: number; } | null; type ConnectionSide = "source" | "target"; type ConnectionMenuState = { nodeId: string; screenX: number; screenY: number; side: ConnectionSide; } | null; type PresetControlAnchor = { bottomY: number; centerX: number; topY: number; }; type ComposerSettings = { camera: string; count: string; model: string; panorama: string; ratio: string; resolution: string; }; type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script"; type CanvasCreateKind = AddModuleKind; type TargetModuleActionLabel = | "自己编写内容" | "文生视频" | "图片反推提示词" | "文字生音乐" | "图生图" | "图片高清" | "音频生视频" | "首尾帧生成视频" | "首帧生成视频" | "剧本生成分镜脚本" | "视频参考生成分镜脚本" | "角色生成分镜脚本"; type ImageToolbarAction = | "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | "annotate" | "rotate" | "download" | "preview"; type ComposerSubmitPayload = { prompt: string; references: string[]; settings: ComposerSettings; slashScene: string | null; }; type LiblibVideoCanvasReplicaProps = { canvasId: string; isLoading?: boolean; }; const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[]; const capturedCounts = (capturedCanvasModules as any).counts || {}; const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[]; const TOOL_TYPE_META: Record< string, { label: string; icon: LucideIcon; accent: string; inputType?: CloneNode["inputType"] } > = { image: { label: "图片", icon: ImageIcon, accent: "#8b96ff", inputType: "image" }, video: { label: "视频", icon: Video, accent: "#8b96ff", inputType: "video" }, text: { label: "文本", icon: FileText, accent: "#f0d06b", inputType: "text" }, audio: { label: "音频", icon: Music2, accent: "#9ee6bb", inputType: "audio" }, compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" }, }; const ADD_NODE_MODULES: Array<{ badge?: string; description: string; icon: LucideIcon; kind: AddModuleKind; label: string; }> = [ { kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText }, { kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon }, { kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video }, { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, { kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" }, ]; const CONNECTION_ADD_MODULES: Array<{ icon: LucideIcon; kind: Exclude; label: string; }> = [ { kind: "text", label: "文本生成", icon: FileText }, { kind: "image", label: "图片生成", icon: ImageIcon }, { kind: "video", label: "视频生成", icon: Video }, { kind: "video-clip", label: "视频合成", icon: Scissors }, { kind: "script", label: "脚本生成", icon: Layers }, ]; const TARGET_MODULE_CENTER_OFFSETS: Record = { audio: { x: 705, y: -257 }, image: { x: 53, y: -257 }, script: { x: 556, y: 253 }, text: { x: -742, y: -257 }, video: { x: -574, y: 253 }, "video-clip": { x: 80, y: 253 }, }; const STARTER_CARDS = [ { title: "故事脚本生成", subtitle: "脚本到分镜", coverUrl: "https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/f9d23335-4ec2-412d-9954-91c58f4b5eb3.png", steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"], }, { title: "角色三视图", subtitle: "角色资产沉淀", coverUrl: "https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/e3731aeb-a95b-46e9-99e7-3cd7f22a1e3f.png", steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"], }, { title: "首帧图生视频", subtitle: "参考图到成片", coverUrl: "https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/f407cb9c-8dd7-436b-8e80-88da1cebf25d.png", steps: ["上传图片", "首帧图", "可灵 3.0", "预览"], }, { title: "音频生视频", subtitle: "音频驱动画面", coverUrl: "https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/bd88df14-04bd-473d-9016-ec72a5273bfb.png", icon: "music", steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"], }, ]; const TOOLBAR_ITEMS: Array<{ id: Exclude; label: string; icon: LucideIcon }> = [ { id: "add", label: "添加模块", icon: Plus }, { id: "workflow", label: "工作流", icon: GitBranch }, { id: "assets", label: "素材", icon: Layers }, { id: "history", label: "历史", icon: Clock3 }, { id: "help", label: "帮助", icon: HelpCircle }, { id: "support", label: "客服", icon: Headphones }, ]; const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 }; const PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 }; const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 }; const CANVAS_EXTENT = 20000; const OFFICIAL_NODE_GAP = 100; const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232; const OFFICIAL_TEXT_RESOURCE_WIDTH = 350; const TARGET_AVATAR = "https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp"; const CHARACTER_IMAGE_URL = "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/cca9e085-dccc-4f72-aef7-d68dc6688bcf.png"; const CHARACTER_THREE_VIEW_URL = "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/7a32ba0b-7b5b-46b1-a86c-e0904482adf7.png"; const FIRST_FRAME_IMAGE_URL = "https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/da241fab-3569-49aa-ae4e-c20de9f718e0.png"; const AUDIO_SOURCE_URL = "https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/3ca0886d-d35f-4048-95bf-cc655e9c57fd.mp3"; const AUDIO_REFERENCE_IMAGE_URL = "https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/1342d344-2d95-41a1-a2aa-4d20869a318c.png"; const PUBLISH_MODAL_BG_URL = "https://libtv-res.liblib.art/upload-images/4e7115e6ee6947b78d73e0203a85fc56/771d92a21c6dce69a97513d21ee3253fe0eedec9.png"; const MEMBER_CARD_THEMES: Record = { "lib-nano": { bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/3b7bcfd5-d1d3-4545-8089-53c04bad6ede.svg", icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/8d5f4fb5-8132-4049-b902-7eca7db0de1d.svg", tone: "#2A2038", }, "seedance-2": { bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/a520a76c-8101-4be3-8c3b-30a37b2320d9.svg", icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/9207509b-a5d5-47af-944b-1894b469735f.svg", tone: "#222A3E", }, "seedance-2-1080p": { bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/a32e399e-8353-4cdf-a44e-e7e08cca8c7f.svg", icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/9207509b-a5d5-47af-944b-1894b469735f.svg", tone: "#222222", }, }; const MEMBER_MODEL_CARDS = [ { title: "Seedance 2.0", label: "1080P 专享", power: "45,000", price: "2,999", corner: "首购加赠45000", desc: "大师版及以上会员可购买", group: "seedance-2-1080p", }, { title: "Seedance 2.0", label: "1080P 专享", power: "60,000", price: "3,999", corner: "首购加赠60000", desc: "大师版及以上会员可购买", group: "seedance-2-1080p", }, { title: "Lib Nano Pro", label: "模型积分卡", power: "20,000", price: "1,999", corner: "多生成571张图片", desc: "尊享版可购买", group: "lib-nano", }, { title: "Lib Nano 2", label: "模型积分卡", power: "45,000", price: "2,999", corner: "多生成1500张图片", desc: "尊享版可购买", group: "lib-nano", }, { title: "Seedance 2.0", label: "视频模型卡", power: "7,500", price: "599", corner: "多生成341秒视频", desc: "大师版及以上会员可购买", group: "seedance-2", }, { title: "Seedance 2.0", label: "视频模型卡", power: "15,000", price: "999", corner: "多生成682秒视频", desc: "大师版及以上会员可购买", group: "seedance-2", }, ]; const VIP_PLAN_CARDS = [ { title: "基础版VIP", power: "每月 8,000 点积分", price: "39", benefits: ["登录每日赠送20积分", "云端存储空间20GB", "会员专属模型"] }, { title: "大师版VIP", power: "每月 18,000 点积分", price: "89", benefits: ["无限并发任务", "会员专享无限次加速", "Seedance 2.0 活动权益"] }, { title: "尊享版VIP", power: "每月 34,000 点积分", price: "169", benefits: ["云端存储空间500GB", "智能分镜(Kling3.0)", "去除品牌水印"] }, ]; const CREDIT_PACKS = [ { points: "1,000", price: "6", tag: "限时 6 折" }, { points: "5,000", price: "30", tag: "首充推荐" }, { points: "10,000", price: "60", tag: "最高赠送" }, { points: "30,000", price: "180", tag: "创作囤货" }, ]; 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宫格连贯分镜", "电影级光影校正", "角色三视图生成", "画面推演-3秒后", "画面推演-5秒前", ]; const STORY_SCRIPT_TEXT = `《我在盛唐写天下》 类型:古风 / 穿越 / 爽文漫剧 时长建议:60-90秒 基调:热血 x 盛唐史诗感 x 爽点节奏 【序幕】 【现代 · 深夜办公室】 键盘声急促。电脑屏幕蓝光刺眼。沈昭昭(女,28岁),面色苍白,伏案加班。手机提示音不断。她抬头,视线模糊,黑屏。 【第一幕】 【盛唐 · 金銮殿 · 日】 鼓声震天。沈昭昭猛然睁眼,已跪在大殿中央,身着男装官服,双手被押。百官列立,殿宇巍峨。 大臣厉声:“大逆不道!假称诗才惊世,欺君罔上,当斩!” 沈昭昭震惊低语:“这是……唐朝?” 龙椅之上,皇帝目光沉沉。 皇帝:“既言才华盖世,当殿作诗。若不能,斩。” 刀光映入她瞳孔。大殿死寂。 【第二幕】 沈昭昭缓缓起身。呼吸渐稳。她望向殿外,长安城阳光万里。 她低声:“既来盛唐……便与盛唐争一争。” 抬头,高声吟诵:“君不见黄河之水天上来。” 声音回荡大殿。群臣震动。皇帝缓缓站起。诗声激昂,如惊雷落殿。 【第三幕】 殿外风起。长安街市灯火连绵。 皇帝低声:“此诗,当传万世。” 沈昭昭目光坚定。她轻声自语:“既然来了……那便写尽三万里长安。” 镜头推远。盛唐山河展开。`; function normalizeCanvasTitle(title?: string | null) { const value = String(title || "").trim(); if (!value || value === "未命名视频画布") return "未命名"; return value; } function getToolType(tool?: CapturedTool) { return String(tool?.type || "video").trim() || "video"; } function getToolLabel(tool?: CapturedTool) { return String(tool?.modelName || tool?.scene || tool?.modelKey || tool?.toolKey || "未命名模块").trim(); } function getToolModes(tool?: CapturedTool) { return (tool?.modes || []).map(String).filter(Boolean).slice(0, 4); } function getToolProperties(tool?: CapturedTool) { const properties = tool?.metadata?.properties; if (!properties || typeof properties !== "object") return []; return Object.entries(properties).filter(([key]) => key !== "modeType"); } function getToolRules(tool?: CapturedTool) { const rules = tool?.rules || tool?.metadata?.rules; return Array.isArray(rules) ? rules : []; } function requiresMedia(tool?: CapturedTool) { return getToolRules(tool).some((rule) => { if (!rule || typeof rule !== "object") return false; const required = (rule as { require?: unknown }).require; return Array.isArray(required) && required.map(String).includes("media"); }); } function getPropertyLabel(key: string, config: unknown) { if (config && typeof config === "object" && "displayName" in config) { const value = (config as { displayName?: unknown }).displayName; if (typeof value === "string" && value.trim()) return value; } const labels: Record = { count: "生成数量", duration: "时长", enableSound: "生成音效", frameRate: "帧率", instrumental: "纯音乐", lora: "风格", prompt: "提示词", quality: "清晰度", ratio: "比例", resolution: "分辨率", searchable: "联网搜索", search_enabled: "联网搜索", slowMotion: "慢动作", slowmo: "减速强度", specifiedModel: "模型", stability: "稳定性", style: "模型", template: "风格", voice: "声音", }; return labels[key] || key; } function getOptions(config: unknown) { if (Array.isArray(config)) return config; if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) { return (config as { enum: unknown[] }).enum; } return []; } function getOptionValue(option: unknown) { if (option && typeof option === "object" && "value" in option) { return String((option as { value?: unknown }).value ?? ""); } return String(option ?? ""); } function getOptionLabel(option: unknown) { if (option && typeof option === "object" && "displayName" in option) { const label = (option as { displayName?: unknown }).displayName; if (label) return String(label); } return getOptionValue(option); } function getDefaultValue(key: string, config: unknown) { if (config && typeof config === "object" && "default" in config) { return String((config as { default?: unknown }).default ?? ""); } const options = getOptions(config); if (options.length > 0) return getOptionValue(options[0]); if (typeof config === "boolean") return config ? "1" : "0"; if (key === "prompt") return ""; return ""; } function getInspectorTitle(node: CloneNode) { if (node.kind !== "input") return node.title; if (node.inputType === "video") return "视频输入"; if (node.inputType === "audio") return "音频输入"; if (node.inputType === "text") return "文本输入"; return "图片输入"; } function makeId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } function serializeNodes(nodes: CloneNode[]) { return nodes.map((node) => ({ id: node.id, type: "workflow", position: { x: node.x, y: node.y }, size: { width: node.width, height: node.height }, data: { title: node.title, description: node.subtitle || "", status: "pending", templateType: `libtv-${node.kind}`, metadata: { liblibCloneNode: node, }, }, connections: [], createdAt: new Date(), updatedAt: new Date(), })); } function serializeEdges(edges: CloneEdge[]) { return edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, type: "default", })); } function restoreNodes(canvasData: unknown): CloneNode[] { const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes; if (!Array.isArray(nodes)) return []; return nodes .map((item, index) => { const metadata = ((item as any)?.data?.metadata || {}) as Record; const clone = metadata.liblibCloneNode as CloneNode | undefined; return clone && typeof clone.id === "string" ? normalizeRestoredNode(clone, index + 1) : null; }) .filter(Boolean) as CloneNode[]; } function restoreEdges(canvasData: unknown): CloneEdge[] { const edges = (canvasData as { edges?: unknown[] } | null)?.edges; if (!Array.isArray(edges)) return []; return edges .map((edge) => ({ id: String((edge as any).id || makeId("edge")), source: String((edge as any).source || ""), target: String((edge as any).target || ""), })) .filter((edge) => edge.source && edge.target); } function createInputNode(inputType: CloneNode["inputType"], x: number, y: number): CloneNode { const title = inputType === "video" ? "上传视频" : inputType === "audio" ? "上传音频" : inputType === "text" ? "输入文本" : "上传图片"; return { id: makeId("input"), kind: "input", title, subtitle: inputType === "video" ? "添加视频素材、参考视频或首尾帧" : inputType === "audio" ? "添加音乐、语音或音效参考" : inputType === "text" ? "输入脚本、分镜或提示词" : "添加参考图、角色图或关键帧素材", inputType, x, y, width: 158, height: 158, accent: "#8790ff", }; } function createToolNode(tool: CapturedTool, x: number, y: number): CloneNode { const type = getToolType(tool); const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; const isAudio = type === "audio"; const isText = type === "text"; return { id: makeId("tool"), kind: isAudio ? "audio" : isText ? "text" : "model", title: getToolLabel(tool), subtitle: tool.vendor || meta.label, x, y, width: isText ? 210 : 194, height: isText ? 122 : 116, accent: meta.accent, tool, draftValues: {}, }; } function createWorkflowNode(workflow: WorkflowPreset, x: number, y: number): CloneNode { return { id: makeId("workflow"), kind: "workflow", title: String(workflow.name || workflow.title || "工作流预设"), subtitle: workflow.description || "LibTV workflow", x, y, width: 230, height: 116, accent: "#8b96ff", workflow, draftValues: {}, }; } function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: number): CloneNode { return { id: makeId(kind), kind, title, subtitle: kind === "preview" ? "预览结果" : kind === "save" ? "保存到本地" : "输入内容", x, y, width: kind === "text" ? 210 : 194, height: kind === "text" ? 122 : 116, accent: kind === "save" || kind === "preview" ? "#8b96ff" : "#f0d06b", inputType: kind === "text" ? "text" : undefined, draftValues: {}, }; } function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode { if (kind === "script") { return { id: makeId("script"), kind: "script", title: `脚本 ${index}`, subtitle: "脚本生成", inputType: "text", x, y, width: 438, height: 470, accent: "#f0d06b", draftValues: { action: "SCRIPT_GENERATE", generatorType: "DEFAULT", nodeType: "script", rows: [], scriptRole: "generator", viewMode: "table", }, }; } if (kind === "video-clip") { return { id: makeId("video-clip"), kind: "model", title: `视频合成 ${index}`, subtitle: "视频合成", inputType: "video", x, y, width: 436, height: 470, accent: "#ffb8c8", draftValues: { action: "VIDEO_CLIP_RESOURCE", nodeType: "video-clip", params: {}, url: [], }, }; } const meta: Record, { action: string; accent: string; kind: CloneNodeKind; nodeType: NonNullable; subtitle: string; title: string; }> = { audio: { action: "AUDIO_GENERATE", accent: "#9ee6bb", kind: "audio", nodeType: "audio", subtitle: "音频生成", title: `音频节点 ${index}`, }, image: { action: "IMAGE_GENERATE", accent: "#8b96ff", kind: "model", nodeType: "image", subtitle: "图片生成", title: `图片节点 ${index}`, }, text: { action: "TEXT_GENERATE", accent: "#f0d06b", kind: "text", nodeType: "text", subtitle: "文本生成", title: `文本节点 ${index}`, }, video: { action: "VIDEO_GENERATE", accent: "#8b96ff", kind: "model", nodeType: "video", subtitle: "视频生成", title: `视频节点 ${index}`, }, }; const item = meta[kind]; const isWideModule = item.nodeType === "image" || item.nodeType === "video"; return { id: makeId(kind), kind: item.kind, title: item.title, subtitle: item.subtitle, inputType: item.nodeType, x, y, width: isWideModule ? 773 : 436, height: 470, accent: item.accent, draftValues: { action: item.action, alt: item.nodeType === "image" ? `图片 ${index}` : undefined, content: item.nodeType === "text" ? [] : undefined, generatorType: "DEFAULT", nodeType: item.nodeType, poster: item.nodeType === "video" ? "" : undefined, url: item.nodeType === "text" ? undefined : [], }, }; } function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode { const mediaType: NonNullable = file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "image"; const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; const fallbackName = `${label}节点 ${index}`; const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName; return { id: makeId(mediaType), kind: "media", title, subtitle: `${label}资源`, inputType: mediaType, x, y, width: mediaType === "audio" ? 430 : 420, height: mediaType === "audio" ? 170 : 280, accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", draftValues: { action: `${mediaType.toUpperCase()}_RESOURCE`, alt: file.name, generated: false, mediaType, mediaUrl, nodeType: mediaType, resourceMeta: { items: [{ name: file.name, size: file.size, type: file.type }], }, url: [mediaUrl], }, }; } 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 = OFFICIAL_TEXT_RESOURCE_HEIGHT, title, width = OFFICIAL_TEXT_RESOURCE_WIDTH, 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 : ""; } function getNodeMediaUrl(node: CloneNode | null | undefined) { const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); if (direct) return direct; const urls = node?.draftValues?.url; return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : ""; } 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 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" || nodeType === "image" || nodeType === "video" || nodeType === "video-clip" || nodeType === "audio" || nodeType === "script" ) { return nodeType; } return null; } function extractTrailingNumber(value: string | undefined) { const match = String(value || "").match(/(\d+)\s*$/); return match ? Number.parseInt(match[1], 10) : null; } function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null { if (node.kind === "media" || getPresetMode(node)) return null; const action = getDraftString(node, "action"); if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null; const nodeType = getDraftString(node, "nodeType"); if (nodeType === "text" || nodeType === "image" || nodeType === "video" || nodeType === "audio" || nodeType === "script") { return nodeType; } if (nodeType === "video-clip" || nodeType === "video_clip" || nodeType === "videoGroup") return "video-clip"; const haystack = `${node.title} ${node.subtitle || ""} ${node.kind} ${node.inputType || ""} ${action}`.toLowerCase(); if (/脚本|script/.test(haystack)) return "script"; if (/视频合成|视频组|video-clip|video_clip|video_group|clip/.test(haystack)) return "video-clip"; if (/音频|audio/.test(haystack)) return "audio"; if (/图片|image/.test(haystack)) return "image"; if (/视频|video/.test(haystack)) return "video"; if (/文本|文字|text/.test(haystack)) return "text"; return null; } function normalizeTextResourceNode(node: CloneNode): CloneNode { const shouldUseOfficialSize = Boolean(node.draftValues?.manualEditing) || /^(文本|文本生成|文本节点)/.test(String(node.title || "")); return { ...node, kind: "script", title: node.title && !/文本生成|文本节点/.test(node.title) ? node.title : "文本", subtitle: "文本资源", inputType: "text", width: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_WIDTH : node.width > 260 ? node.width : OFFICIAL_TEXT_RESOURCE_WIDTH, height: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_HEIGHT : node.height > 120 ? node.height : OFFICIAL_TEXT_RESOURCE_HEIGHT, accent: "#f0d06b", draftValues: { ...(node.draftValues || {}), action: "TEXT_RESOURCE", content: Array.isArray(node.draftValues?.content) ? node.draftValues?.content : [], nodeType: "text", scriptRole: "resource", viewMode: undefined, }, }; } function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModuleKind) { const value = String(title || "").trim(); if (!value) return true; const legacyTitlePattern = /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点)(\s*\d+)?$/; if (!legacyTitlePattern.test(value)) return false; if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value); const officialPrefix = kind === "text" ? "文本节点" : kind === "image" ? "图片节点" : kind === "video" ? "视频节点" : kind === "audio" ? "音频节点" : "脚本"; return !new RegExp(`^${officialPrefix}\\s*\\d+$`).test(value); } function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbackIndex: number): CloneNode { const index = extractTrailingNumber(node.title) || fallbackIndex; const official = createTargetModuleNode(kind, node.x, node.y, index); const isOfficialShape = Math.abs(node.width - official.width) <= 2 && Math.abs(node.height - official.height) <= 2; const resetTitle = shouldResetTargetModuleTitle(node.title, kind); if (isOfficialShape && !resetTitle) return node; return { ...official, id: node.id, x: node.x, y: node.y, title: resetTitle ? official.title : node.title, draftValues: { ...(official.draftValues || {}), ...(node.draftValues || {}), nodeType: kind, }, }; } function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode { const action = getDraftString(node, "action"); if (action === "TEXT_RESOURCE" && !getPresetMode(node)) { return normalizeTextResourceNode(node); } const kind = getTargetModuleKind(node) || inferLegacyTargetModuleKind(node); if (!kind) return node; return normalizeTargetModuleNode(node, kind, fallbackIndex); } 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 = 100; const left = createPresetMediaNode({ action: "IMAGE_RESOURCE", height: 520, mediaType: "image", mediaUrl: CHARACTER_IMAGE_URL, preset, title: "角色图", width: 874, x: Math.round(center.x - 874 - distance / 2), y: Math.round(center.y - 320), }); const right = createPresetMediaNode({ action: "IMAGE_GENERATE", generated: true, height: 520, mediaType: "image", mediaUrl: CHARACTER_THREE_VIEW_URL, preset, title: "角色三视图", width: 660, 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); const dragRef = useRef(null); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); const [projectTitle, setProjectTitle] = useState("未命名"); const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); const [activePanel, setActivePanel] = useState(null); const [isProjectLoading, setIsProjectLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [, setShareEnabled] = useState(false); const [publishPublic, setPublishPublic] = useState(false); const [canvasCommand, setCanvasCommand] = useState(null); const [nodeContextMenu, setNodeContextMenu] = useState(null); const [connectionMenu, setConnectionMenu] = useState(null); const [previewNode, setPreviewNode] = useState(null); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, [nodes, selectedNodeId] ); const contextNode = useMemo( () => nodes.find((node) => node.id === nodeContextMenu?.nodeId) || null, [nodeContextMenu?.nodeId, nodes] ); const connectionNode = useMemo( () => nodes.find((node) => node.id === connectionMenu?.nodeId) || null, [connectionMenu?.nodeId, nodes] ); useEffect(() => { let active = true; async function loadProject() { setIsProjectLoading(true); const result = await canvasService.getProject(canvasId); if (!active) return; if (result.success && result.data) { const title = normalizeCanvasTitle(result.data.title); setProjectTitle(title); setDraftTitle(title); const restoredNodes = restoreNodes(result.data.canvasData); const restoredEdges = restoreEdges(result.data.canvasData); setNodes(restoredNodes); setEdges(restoredEdges); } else { setProjectTitle("未命名"); setDraftTitle("未命名"); await canvasService.saveCanvasState( canvasId, { nodes: [], edges: [], selectedNodeIds: [], viewport: DEFAULT_VIEWPORT, tool: "select", }, "video" ); } setIsProjectLoading(false); } void loadProject(); return () => { active = false; }; }, [canvasId]); const saveCanvas = useCallback(async () => { setIsSaving(true); const result = await canvasService.saveCanvasState( canvasId, { nodes: serializeNodes(nodes) as any, edges: serializeEdges(edges) as any, selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select", }, "video" ); if (result.success) { toast.success("已保存"); } else { toast.error(result.error || "保存失败"); } setIsSaving(false); }, [canvasId, edges, nodes, selectedNodeId, viewport]); const commitTitle = useCallback(async () => { const nextTitle = draftTitle.trim() || "未命名"; setProjectTitle(nextTitle); setDraftTitle(nextTitle); setIsTitleEditing(false); await canvasService.updateProject(canvasId, { title: nextTitle }, "video"); }, [canvasId, draftTitle]); const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => { setNodes((current) => [...current, ...nextNodes]); setEdges((current) => [...current, ...nextEdges]); const last = nextNodes[nextNodes.length - 1]; if (last) setSelectedNodeId(last.id); }, []); const addWorkflowPreset = useCallback( (workflow: WorkflowPreset, index = 0) => { const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18); addNodes([node], []); setActivePanel(null); setConnectionMenu(null); toast.success("已添加工作流"); }, [addNodes] ); const toCanvasPoint = useCallback( (clientX: number, clientY: number) => ({ x: Math.round((clientX - viewport.x) / viewport.zoom), y: Math.round((clientY - viewport.y) / viewport.zoom), }), [viewport.x, viewport.y, viewport.zoom] ); const addTargetModule = useCallback( (kind: AddModuleKind, point?: { x: number; y: number }) => { const viewportCenter = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const offset = TARGET_MODULE_CENTER_OFFSETS[kind]; const sameKindCount = nodes.filter((node) => getTargetModuleKind(node) === kind).length; const center = point || { x: viewportCenter.x + offset.x + sameKindCount * 28, y: viewportCenter.y + offset.y + sameKindCount * 28, }; const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); const node = { ...created, x: Math.round(center.x - created.width / 2), y: Math.round(center.y - created.height / 2), }; addNodes([node], []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success(`已添加 ${node.title}`); }, [addNodes, nodes.length, toCanvasPoint] ); const addUploadedFilesAtPoint = useCallback( (files: File[], center: { x: number; y: number }) => { if (files.length === 0) return; const newNodes = files.slice(0, 10).map((file, index) => { const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1); return { ...created, x: Math.round(center.x - created.width / 2 + index * 450), y: Math.round(center.y - created.height / 2), }; }); addNodes(newNodes, []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`); }, [addNodes, nodes.length] ); const addUploadedFiles = useCallback( (files: File[]) => { addUploadedFilesAtPoint(files, toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2)); }, [addUploadedFilesAtPoint, toCanvasPoint] ); const addCanvasCommandUploadedFiles = useCallback( (files: File[]) => { if (!canvasCommand) return; addUploadedFilesAtPoint(files, { x: canvasCommand.canvasX, y: canvasCommand.canvasY }); }, [addUploadedFilesAtPoint, canvasCommand] ); const openCanvasCommand = useCallback( (event: ReactMouseEvent) => { if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; event.preventDefault(); const point = toCanvasPoint(event.clientX, event.clientY); setSelectedNodeId(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setCanvasCommand({ canvasX: point.x, canvasY: point.y, screenX: event.clientX, screenY: event.clientY, }); }, [toCanvasPoint] ); const openNodeContext = useCallback((event: ReactMouseEvent, node: CloneNode) => { event.preventDefault(); event.stopPropagation(); setSelectedNodeId(node.id); setCanvasCommand(null); setConnectionMenu(null); setNodeContextMenu({ nodeId: node.id, screenX: event.clientX, screenY: event.clientY, }); }, []); const openConnectionMenu = useCallback((event: ReactMouseEvent, node: CloneNode, side: ConnectionSide) => { event.preventDefault(); event.stopPropagation(); setSelectedNodeId(node.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setConnectionMenu({ nodeId: node.id, screenX: event.clientX, screenY: event.clientY, side, }); }, []); const addConnectedModule = useCallback( (kind: Exclude) => { if (!connectionMenu) return; const anchor = nodes.find((node) => node.id === connectionMenu.nodeId); if (!anchor) return; const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); const gap = OFFICIAL_NODE_GAP; const node = connectionMenu.side === "source" ? { ...created, x: Math.round(anchor.x + anchor.width + gap), y: Math.round(anchor.y + anchor.height / 2 - created.height / 2), } : { ...created, x: Math.round(anchor.x - created.width - gap), y: Math.round(anchor.y + anchor.height / 2 - created.height / 2), }; const edge = connectionMenu.side === "source" ? { id: makeId("edge"), source: anchor.id, target: node.id } : { id: makeId("edge"), source: node.id, target: anchor.id }; addNodes([node], [edge]); setConnectionMenu(null); toast.success(`已添加并连接 ${node.title}`); }, [addNodes, connectionMenu, nodes] ); const runTargetModuleAction = useCallback( (nodeId: string, actionLabel: TargetModuleActionLabel) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP); const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); 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 "自己编写内容": { anchorUpdates = { kind: "script", title: "文本", subtitle: "文本资源", inputType: "text", width: OFFICIAL_TEXT_RESOURCE_WIDTH, height: OFFICIAL_TEXT_RESOURCE_HEIGHT, }; draftUpdates = { action: "TEXT_RESOURCE", content: [], manualEditing: true, nodeType: "text", scriptRole: "resource", viewMode: undefined, }; 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: leftOfAnchor(420), 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 + OFFICIAL_NODE_GAP), }); 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: leftOfAnchor(420), 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: leftOfAnchor(420), y: Math.round(anchor.y + 18), }); const last = createMediaResourceNode({ mediaType: "image", mediaUrl: VIDEO_FIRST_LAST_LAST_URL, title: "尾帧", width: 420, height: 240, x: leftOfAnchor(420), y: Math.round(anchor.y + 286), }); connectToAnchor(first); connectToAnchor(last); selectedId = anchor.id; break; } case "剧本生成分镜脚本": { connectToAnchor( createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftOfAnchor(OFFICIAL_TEXT_RESOURCE_WIDTH), y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT), }) ); 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: leftOfAnchor(560), 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: leftOfAnchor(300), y: Math.round(anchor.y + 10), }); const second = createMediaResourceNode({ mediaType: "image", mediaUrl: SCRIPT_CHARACTER_TWO_URL, title: "角色 2", width: 300, height: 300, x: leftOfAnchor(300), 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 runSelectedImageToolAction = useCallback( (nodeId: string, action: ImageToolbarAction, option?: string) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; if (action === "download") { if (!sourceUrl) { toast.error("当前素材没有可下载地址"); return; } const link = document.createElement("a"); link.href = sourceUrl; link.download = `${anchor.title || "libtv-image"}.png`; link.rel = "noreferrer"; document.body.appendChild(link); link.click(); link.remove(); toast.success("已开始下载当前素材"); return; } if (action === "preview") { setPreviewNode(anchor); return; } if (action === "annotate") { setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: { ...(node.draftValues || {}), annotationMode: true, annotations: node.draftValues?.annotations || [{ label: "主体", x: 42, y: 46 }], }, } : node ) ); setSelectedNodeId(anchor.id); toast.success("已进入标注模式"); return; } if (action === "rotate") { const currentRotation = Number(anchor.draftValues?.rotation || 0); setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: { ...(node.draftValues || {}), rotation: (currentRotation + 90) % 360, }, } : node ) ); setSelectedNodeId(anchor.id); toast.success("已旋转当前素材"); return; } const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); const nextNodes: CloneNode[] = []; const nextEdges: CloneEdge[] = []; if (action === "crop") { const parsed = Number.parseInt(option || "9", 10); const tileCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 9; const columns = Math.ceil(Math.sqrt(tileCount)); const tileSize = 180; const gap = 26; const rows = Math.ceil(tileCount / columns); const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2); Array.from({ length: tileCount }).forEach((_, index) => { const tile = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: tileSize, mediaType: "image", mediaUrl: sourceUrl, prompt: option || "宫格切分", title: `切分 ${index + 1}`, width: tileSize, x: rightX + (index % columns) * (tileSize + gap), y: startY + Math.floor(index / columns) * (tileSize + gap), }); nextNodes.push(tile); nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id }); }); setNodes((current) => [...current, ...nextNodes]); setEdges((current) => [...current, ...nextEdges]); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(`已创建「${option || "宫格切分"}」结果节点`); return; } const labels: Record, string> = { grid: "九宫格", hd: "高清", lighting: "打光", orbit: "多角度", panorama: "720 全景", }; const title = labels[action]; const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); const prompt = option?.trim() || title; const resultNode: CloneNode = { ...created, title, y: centerY(created.height), draftValues: { ...(created.draftValues || {}), generatorType: action === "hd" ? "ENHANCE" : "DEFAULT", params: { mode: action, prompt, sourceNodeId: anchor.id, sourceUrl, }, sourceNodeId: anchor.id, sourceUrl, toolbarOption: option || "", }, }; setNodes((current) => [...current, resultNode]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(`已创建「${title}」工作流节点`); }, [nodes] ); const updateTextResourceContent = useCallback((nodeId: string, content: string) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), content: content ? [content] : [], }, } : node ) ); }, []); const submitSelectedImagePrompt = useCallback( (nodeId: string, payload: ComposerSubmitPayload) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); const prompt = payload.prompt.trim() || payload.slashScene || "生成角色三视图"; const resultNode: CloneNode = { ...created, title: payload.slashScene || "图片生成", y: Math.round(anchor.y + anchor.height / 2 - created.height / 2), draftValues: { ...(created.draftValues || {}), params: { prompt, references: payload.references, settings: payload.settings, slashScene: payload.slashScene, sourceNodeId: anchor.id, sourceUrl, }, sourceNodeId: anchor.id, sourceUrl, }, }; const referenceEdges = payload.references .filter((referenceId) => referenceId !== anchor.id && nodes.some((node) => node.id === referenceId)) .map((referenceId) => ({ id: makeId("edge"), source: referenceId, target: resultNode.id })); setNodes((current) => [...current, resultNode]); setEdges((current) => [ ...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }, ...referenceEdges, ]); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success("已按当前提示词创建图片生成节点"); }, [nodes] ); const createFreeNode = useCallback( (kind: CanvasCreateKind) => { if (!canvasCommand) return; const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); const node = { ...created, x: Math.round(canvasCommand.canvasX - created.width / 2), y: Math.round(canvasCommand.canvasY - created.height / 2), }; addNodes([node], []); setCanvasCommand(null); setConnectionMenu(null); toast.success(`已添加 ${node.title}`); }, [addNodes, canvasCommand, nodes.length] ); const configureNode = useCallback((nodeId: string) => { setSelectedNodeId(nodeId); setNodeContextMenu(null); setConnectionMenu(null); }, []); const copyNode = useCallback(async (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; await navigator.clipboard?.writeText(node.title).catch(() => undefined); setNodeContextMenu(null); setConnectionMenu(null); toast.success("节点名称已复制"); }, [nodes]); const duplicateNode = useCallback((nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const duplicate = { ...node, id: makeId("copy"), x: node.x + 32, y: node.y + 32, }; setNodes((current) => [...current, duplicate]); setSelectedNodeId(duplicate.id); setNodeContextMenu(null); setConnectionMenu(null); toast.success("已复制节点"); }, [nodes]); const runNode = useCallback((nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; setSelectedNodeId(nodeId); setNodeContextMenu(null); setConnectionMenu(null); toast.message(`${node.title} 生成接口待接入。`); }, [nodes]); const deleteNode = useCallback((nodeId: string) => { setNodes((current) => current.filter((node) => node.id !== nodeId)); setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); setSelectedNodeId((current) => (current === nodeId ? null : current)); setNodeContextMenu(null); setConnectionMenu(null); toast.success("节点已删除"); }, []); const createStarter = useCallback( (starter: (typeof STARTER_CARDS)[number]) => { const workflow = createStarterWorkflow(starter.title, PRESET_WORKFLOW_CENTER); if (workflow) { addNodes(workflow.nodes, workflow.edges); setSelectedNodeId(workflow.selectedId); setViewport(PRESET_VIEWPORT); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(`已载入 ${starter.title} 工作流`); return; } const baseX = 380; const baseY = 325; const newNodes = starter.steps.map((title, index) => { if (/上传图片|首帧图/.test(title)) return createInputNode("image", baseX + index * 270, baseY); if (/上传音频|音频/.test(title) && index === 0) return createInputNode("audio", baseX + index * 270, baseY); if (/脚本|描述/.test(title)) return createSimpleNode(title, "text", baseX + index * 270, baseY + 18); if (/预览/.test(title)) return createSimpleNode(title, "preview", baseX + index * 270, baseY + 18); if (/保存/.test(title)) return createSimpleNode(title, "save", baseX + index * 270, baseY + 18); const matchedTool = capturedTools.find((tool) => getToolLabel(tool).includes(title) || title.includes(getToolLabel(tool))); return matchedTool ? createToolNode(matchedTool, baseX + index * 270, baseY + 18) : createSimpleNode(title, "workflow", baseX + index * 270, baseY + 18); }); const newEdges = newNodes.slice(1).map((node, index) => ({ id: makeId("edge"), source: newNodes[index].id, target: node.id, })); addNodes(newNodes, newEdges); }, [addNodes] ); const updateNodeDraft = useCallback((key: string, value: unknown) => { setNodes((current) => current.map((node) => node.id === selectedNodeId ? { ...node, draftValues: { ...(node.draftValues || {}), [key]: value } } : node ) ); }, [selectedNodeId]); const startNodeDrag = useCallback((event: ReactPointerEvent, node: CloneNode) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setSelectedNodeId(node.id); setCanvasCommand(null); setNodeContextMenu(null); dragRef.current = { kind: "node", id: node.id, startX: event.clientX, startY: event.clientY, nodeX: node.x, nodeY: node.y, }; }, []); const startCanvasDrag = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; setSelectedNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); dragRef.current = { kind: "canvas", startX: event.clientX, startY: event.clientY, viewportX: viewport.x, viewportY: viewport.y, }; }, [viewport.x, viewport.y]); useEffect(() => { 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) { event.preventDefault(); deleteNode(selectedNodeId); } if (event.key === "Escape") { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setSelectedNodeId(null); setActivePanel(null); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [deleteNode, selectedNodeId]); const onPointerMove = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag) return; if (drag.kind === "node") { const dx = (event.clientX - drag.startX) / viewport.zoom; const dy = (event.clientY - drag.startY) / viewport.zoom; setNodes((current) => current.map((node) => node.id === drag.id ? { ...node, x: Math.round(drag.nodeX + dx), y: Math.round(drag.nodeY + dy) } : node ) ); return; } setViewport((current) => ({ ...current, x: Math.round(drag.viewportX + event.clientX - drag.startX), y: Math.round(drag.viewportY + event.clientY - drag.startY), })); }, [viewport.zoom] ); const onPointerUp = useCallback(() => { dragRef.current = null; }, []); const zoomBy = useCallback((delta: number) => { setViewport((current) => ({ ...current, zoom: Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2)))), })); }, []); const zoomAtPoint = useCallback((delta: number, clientX: number, clientY: number) => { setViewport((current) => { const nextZoom = Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2)))); if (nextZoom === current.zoom) return current; const worldX = (clientX - current.x) / current.zoom; const worldY = (clientY - current.y) / current.zoom; return { x: Math.round(clientX - worldX * nextZoom), y: Math.round(clientY - worldY * nextZoom), zoom: nextZoom, }; }); }, []); const onCanvasWheel = useCallback( (event: ReactWheelEvent) => { const target = event.target as HTMLElement; const overlay = target.closest("[data-canvas-overlay]"); const canvasNode = target.closest("[data-canvas-node]"); if (overlay && !canvasNode) return; event.preventDefault(); if (event.ctrlKey || event.metaKey) { zoomAtPoint(event.deltaY > 0 ? -0.08 : 0.08, event.clientX, event.clientY); return; } setViewport((current) => ({ ...current, x: Math.round(current.x - event.deltaX), y: Math.round(current.y - event.deltaY), })); }, [zoomAtPoint] ); const copyShareLink = useCallback(async () => { const url = typeof window === "undefined" ? `/canvas/${canvasId}?mode=video&workspaceType=video` : window.location.href; await navigator.clipboard?.writeText(url).catch(() => undefined); setShareEnabled(true); toast.success("分享链接已复制"); }, [canvasId]); const toggleTopPanel = useCallback((panel: Exclude) => { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel((current) => (current === panel ? null : panel)); }, []); const isBusy = isLoading || isProjectLoading; const selectedPresetControlNode = selectedNode?.kind === "media" && selectedNode.inputType !== "audio" ? selectedNode : null; const selectedPresetMode = getPresetMode(selectedPresetControlNode); const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null; const selectedPresetAnchor = useMemo(() => { if (!selectedPresetControlNode) return null; return { bottomY: Math.round(viewport.y + (selectedPresetControlNode.y + selectedPresetControlNode.height) * viewport.zoom), centerX: Math.round(viewport.x + (selectedPresetControlNode.x + selectedPresetControlNode.width / 2) * viewport.zoom), topY: Math.round(viewport.y + selectedPresetControlNode.y * viewport.zoom), }; }, [selectedPresetControlNode, viewport.x, viewport.y, viewport.zoom]); return (
{nodes.map((node) => ( openNodeContext(event, node)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onPointerDown={(event) => startNodeDrag(event, node)} onTextResourceChange={(content) => updateTextResourceContent(node.id, content)} /> ))}
{nodes.length === 0 && !activePanel ? (
双击画布 自由生成节点
{STARTER_CARDS.map((card) => { const cover = card.coverUrl || capturedWorkflows[0]?.cover || ""; return ( ); })}
) : null} {canvasCommand ? ( setCanvasCommand(null)} onCreate={createFreeNode} onGallerySelect={() => { setActivePanel("assets"); setCanvasCommand(null); }} onUploadFiles={addCanvasCommandUploadedFiles} /> ) : null} {nodeContextMenu && contextNode ? ( setNodeContextMenu(null)} onConfigure={() => configureNode(contextNode.id)} onCopy={() => void copyNode(contextNode.id)} onDelete={() => deleteNode(contextNode.id)} onDuplicate={() => duplicateNode(contextNode.id)} onRun={() => runNode(contextNode.id)} /> ) : null} {connectionMenu && connectionNode ? ( setConnectionMenu(null)} onCreate={addConnectedModule} state={connectionMenu} /> ) : null} {selectedPresetMode && selectedPresetAnchor && selectedPresetControlNode ? ( submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} selectedNode={selectedPresetControlNode} /> ) : 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("/")} /> { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel((current) => current === panel ? null : panel); }} /> setViewport(DEFAULT_VIEWPORT)} onZoomIn={() => zoomBy(0.1)} onZoomOut={() => zoomBy(-0.1)} /> {activePanel === "add" ? ( setActivePanel(null)} onGallerySelect={() => setActivePanel("assets")} onUploadFiles={addUploadedFiles} /> ) : null} {activePanel === "workflow" ? ( setActivePanel(null)} /> ) : null} {selectedNode && !selectedPresetMode && !selectedTargetModuleKind ? ( setSelectedNodeId(null)} /> ) : null} {activePanel === "assets" ? ( setActivePanel(null)} title="素材库"> ) : null} {activePanel === "history" ? ( setActivePanel(null)} title="历史记录">
{nodes.length === 0 ? ( ) : ( nodes.slice(-10).reverse().map((node) => ( )) )}
) : null} {activePanel === "help" ? ( setActivePanel(null)} title="快捷键">
{[ ["V", "选择"], ["Space", "拖动画布"], ["双击画布", "自由生成节点"], ["连接点拖拽", "连接模块"], ["⌘ / Ctrl + S", "保存画布"], ].map(([key, value]) => (
{value} {key}
))}
) : null} {activePanel === "support" ? ( setActivePanel(null)} title="支持中心"> ) : null} {activePanel === "skills" ? ( setActivePanel(null)} /> ) : null} {activePanel === "share" ? ( setActivePanel(null)} onCopyLink={() => { void copyShareLink(); setActivePanel(null); }} onPublish={() => setActivePanel("publish")} /> ) : null} {activePanel === "publish" ? ( setActivePanel(null)} onTogglePublic={() => setPublishPublic((value) => !value)} /> ) : null} {activePanel === "member" ? ( setActivePanel(null)} /> ) : null} {activePanel === "notifications" ? ( setActivePanel(null)} /> ) : null} {activePanel === "account" ? ( setActivePanel(null)} /> ) : null} {previewNode ? ( setPreviewNode(null)} title={previewNode.title || "预览"} width={920}> ) : null} {isBusy ? (
项目加载中...
) : null}
); } function TopBar({ draftTitle, isSaving, isTitleEditing, onAccount, onBell, onCommitTitle, onDraftTitleChange, onMember, onSave, onShare, onSkills, onStartTitleEdit, projectTitle, routerBack, }: { draftTitle: string; isSaving: boolean; isTitleEditing: boolean; onAccount: () => void; onBell: () => void; onCommitTitle: () => void; onDraftTitleChange: (value: string) => void; onMember: () => void; onSave: () => void; onShare: () => void; onSkills: () => void; onStartTitleEdit: () => void; projectTitle: string; routerBack: () => void; }) { return (
{isTitleEditing ? ( onDraftTitleChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") onCommitTitle(); }} value={draftTitle} /> ) : ( )}
LibTV Skills {isSaving ? : } 会员超市
); } function TopButton({ children, className, onClick, title, }: { children: React.ReactNode; className?: string; onClick: () => void; title?: string; }) { return ( ); } function LeftToolbar({ activePanel, onOpen, }: { activePanel: ActivePanel; onOpen: (panel: Exclude) => void; }) { return ( ); } function ZoomDock({ compact = false, onCenter, onZoomIn, onZoomOut, zoom, }: { compact?: boolean; onCenter: () => void; onZoomIn: () => void; onZoomOut: () => void; zoom: number; }) { if (compact) { return (
); } return (
); } function LibTVLogo() { return ( ); } function PointerBadge() { return ( ); } function ArrowGlyph() { return ( ); } function MusicStarterIcon() { return ( ); } function StoreGlyph() { return ( ); } function DiamondGlyph() { return ( ); } function LightningGlyph() { return ( ); } function MemberDealPill({ onAccount, onMember }: { onAccount: () => void; onMember: () => void }) { return (
); } function AssetGlyph() { return ( ); } function WorkflowGlyph() { return ( ); } function GridTilesGlyph() { return ( ); } function MapPinGlyph() { return ( ); } function MagnetGlyph() { return ( ); } function PresetWorkflowControls({ anchor, mode, nodes, onComposerSubmit, onImageToolAction, selectedNode, }: { anchor: PresetControlAnchor; mode: Exclude; nodes: CloneNode[]; onComposerSubmit: (payload: ComposerSubmitPayload) => void; onImageToolAction: (action: ImageToolbarAction, option?: string) => void; selectedNode: CloneNode; }) { if (mode === "character-three-view") { return (
); } if (mode === "story-script") { return (
); } if (mode === "first-frame") { return (
); } return (
); } function CharacterPresetToolbar({ anchor, onToolAction, }: { anchor?: PresetControlAnchor; onToolAction: (action: ImageToolbarAction, option?: string) => void; }) { const [toolbarMenu, setToolbarMenu] = useState(null); const iconActions: Array<{ action: ImageToolbarAction; icon: string; label: string }> = [ { action: "annotate", icon: "⌁", label: "标注" }, { action: "rotate", icon: "↔", label: "旋转" }, { action: "download", icon: "↓", label: "下载" }, { action: "preview", icon: "↗", label: "预览" }, ]; const activeToolbarMenu = toolbarMenu; 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")} />
{iconActions.map((item) => ( ))}
{activeToolbarMenu ? ( setToolbarMenu(null)} onSelect={(option) => { onToolAction(activeToolbarMenu, option); setToolbarMenu(null); }} /> ) : null} ); } function StoryScriptScene() { return (
); } function FirstFrameVideoScene() { return (
); } function AudioToVideoScene() { return (
); } function PresetFloatingToolbar({ anchor, items, }: { anchor?: PresetControlAnchor; items: Array<[string, string]>; }) { return (
{items.map(([label, message]) => ( ))}
); } function SceneConnector({ className }: { className?: string }) { return (
); } function StoryTextPanel() { return (
剧本
输入内容
{STORY_SCRIPT_TEXT}
); } function ScriptGeneratorPanel() { const rows = [ ["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"], ["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"], ["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"], ]; return (
脚本生成器
故事脚本 待生成
{["镜号", "场景", "画面", "声音"].map((item) => (
{item}
))}
{rows.map((row) => (
{row.map((cell) => (
{cell}
))}
))}
); } function PresetMediaPanel({ imageUrl, label, small = false, wide = false, }: { imageUrl: string; label: string; small?: boolean; wide?: boolean; }) { return (
{label}
{label}
); } function VideoResultPanel({ label }: { label: string }) { return (
待生成视频
生成后将在这里预览
); } function AudioResourcePanel() { return (
音频
情绪配乐.mp3
音频生视频参考素材
试听
{Array.from({ length: 38 }).map((_, index) => ( ))}
); } function PresetComposerPanel({ anchor, credit, prompt, submitMessage, thumbnailUrl, title, }: { anchor?: PresetControlAnchor; credit: string; prompt: string; submitMessage: string; thumbnailUrl?: string; title: string; }) { const [draftPrompt, setDraftPrompt] = useState(prompt); useEffect(() => { setDraftPrompt(prompt); }, [prompt]); return (
{title} {thumbnailUrl ? (
) : null}