"use client"; import { Bell, ChevronDown, Clock3, Copy, FileText, GitBranch, Headphones, HelpCircle, Image as ImageIcon, Layers, Loader2, Music2, Plus, Play, Scissors, Settings2, Sparkles, Trash2, Upload, UserRound, Video, Wand2, X, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { type ChangeEvent as ReactChangeEvent, type DragEvent as ReactDragEvent, 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 useSWR from "swr"; import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { cn, fetcher } from "@/lib/utils"; import { canvasService, imageGenerationService, videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; import { type VideoCanvasFunctionConfig, type VideoCanvasFunctionDefinition, defaultVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; 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; zoom: number; }; type ComposerSettings = { camera: string; count: string; model: string; panorama: string; ratio: string; resolution: string; }; type CanvasModelOption = { id: string; modelId?: string; displayName: string; providerName?: string; providerType?: string; supportsVision?: boolean; supportsImageGen?: boolean; isMultimodal?: boolean; isDefault?: boolean; isSubModel?: boolean; capabilities?: unknown; }; type CanvasModelKind = "image" | "video" | "multimodal" | "any"; 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; }; type SiteBrandSettings = { siteName?: string | null; siteLogo?: string | null; siteLogoDark?: string | null; }; type MembershipSummaryResponse = { summary?: { currentCredits?: number | null; }; }; type GridCellVisual = { fit?: "cover" | "contain"; objectPosition?: string; scale?: number; translateX?: number; translateY?: number; }; type GridCropRect = { height: number; width: number; x: number; y: number; }; type GridSplitSpec = { columns: number; detected: boolean; rects: GridCropRect[]; rows: number; }; type AngleEditorSettings = { horizontalAngle: number; promptEnabled: boolean; verticalAngle: number; zoom: number; }; 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 = 330; const OFFICIAL_TEXT_RESOURCE_WIDTH = 560; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; const OFFICIAL_COMPOSER_SCALE = 0.546; const OFFICIAL_ZOOM_DOCK_SCALE = 0.65; const OFFICIAL_DROPDOWN_SCALE = 0.7; const OFFICIAL_NINE_GRID_WIDTH = 930; const OFFICIAL_NINE_GRID_HEIGHT = 520; const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4"; const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid"; const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25"; const CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY = "cinematic-lighting-correction"; const CHARACTER_THREE_VIEW_FUNCTION_KEY = "character-three-view"; const FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY = "frame-prediction-plus-3s"; const FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY = "frame-prediction-minus-5s"; const PROMPT_COMPOSER_BASE_WIDTH = 895; const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PRESET_COMPOSER_BASE_HEIGHT = 238; const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [ { fit: "contain", objectPosition: "50% 50%", scale: 0.62 }, { fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 }, { fit: "contain", objectPosition: "68% 50%", scale: 0.86, translateX: -18 }, { objectPosition: "34% 24%", scale: 1.4, translateX: 8, translateY: 8 }, { objectPosition: "52% 28%", scale: 1.55, translateY: 5 }, { objectPosition: "66% 26%", scale: 1.72, translateX: -8, translateY: 2 }, { objectPosition: "20% 72%", scale: 2.08, translateX: 22, translateY: -12 }, { objectPosition: "52% 68%", scale: 1.24, translateY: -4 }, { objectPosition: "70% 64%", scale: 1.46, translateX: -14, translateY: -6 }, ]; 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 createNineGridCompositeNode({ anchor, columns, functionKey = MULTI_VIEW_NINE_GRID_FUNCTION_KEY, gridPrompt, option, rows, sourceUrl, x, y, }: { anchor: CloneNode; columns: number; functionKey?: string; gridPrompt?: string; option?: string; rows: number; sourceUrl: string; x: number; y: number; }): CloneNode { const cellCount = rows * columns; const title = cellCount === 9 ? "多机位九宫格" : option?.trim() || `${cellCount}宫格`; const resolvedGridPrompt = gridPrompt?.trim() || (cellCount === 9 && (!option || /多|9|九/.test(option)) ? getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY) : `基于选中图片生成 ${columns}x${rows} 宫格分镜图,使用黑色分割线分隔每一格。`); return { id: makeId("image"), kind: "media", title, subtitle: "生成结果", inputType: "image", x, y, width: OFFICIAL_NINE_GRID_WIDTH, height: OFFICIAL_NINE_GRID_HEIGHT, accent: "#8790ff", draftValues: { action: "IMAGE_GENERATE_NINE_GRID", functionKey, generated: true, gridColumns: columns, gridPrompt: resolvedGridPrompt, gridRows: rows, mediaType: "image", nineGridCells: Array.from({ length: cellCount }).map((_, index) => getGridCellVisual(index)), nineGridComposite: true, nodeType: "image", outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "", params: { columns, functionKey, mode: "grid", option: option || "", prompt: resolvedGridPrompt, rows, sourceNodeId: anchor.id, sourceUrl, }, prompt: gridPrompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: option || "", url: [], }, }; } 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 getDraftNumber(node: CloneNode | null | undefined, key: string, fallback = 0) { const value = node?.draftValues?.[key]; const numericValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } 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 preventNativeImageDrag(event: ReactDragEvent) { event.preventDefault(); } function normalizeGridOption(option?: string) { const text = String(option || "").trim(); const explicitGrid = text.match(/(\d+)\s*[x×]\s*(\d+)/i); if (explicitGrid) { const columns = Math.max(1, Math.min(5, Number.parseInt(explicitGrid[1] || "3", 10) || 3)); const rows = Math.max(1, Math.min(5, Number.parseInt(explicitGrid[2] || "3", 10) || 3)); return { columns, rows, tileCount: columns * rows }; } const tileCount = /25\s*宫格|5\s*[x×]\s*5|25/.test(text) ? 25 : /16\s*宫格|4\s*[x×]\s*4|16/.test(text) ? 16 : /4\s*宫格|2\s*[x×]\s*2|四/.test(text) ? 4 : 9; const columns = tileCount === 4 ? 2 : tileCount === 16 ? 4 : tileCount === 25 ? 5 : 3; const rows = Math.ceil(tileCount / columns); return { columns, rows, tileCount }; } function getGridCellVisual(index: number): GridCellVisual { return NINE_GRID_CELL_VISUALS[index % NINE_GRID_CELL_VISUALS.length]; } function getNodeSourceMediaUrl(node: CloneNode | null | undefined) { return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node); } function getOfficialImagePresetFunctionKey(value?: unknown) { const text = String(value || "").trim(); if (!text) return null; if (/角色三视图|三视图生成|正侧背|character[-_\s]?(three|turnaround)/i.test(text)) { return CHARACTER_THREE_VIEW_FUNCTION_KEY; } if (/剧情推演|四宫格|4\s*宫格|2\s*[x×]\s*2/.test(text)) { return STORY_BEAT_GRID_FUNCTION_KEY; } if (/25\s*宫格|连贯分镜|5\s*[x×]\s*5/.test(text)) { return CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY; } if (/电影级光影|光影校正|cinematic lighting/i.test(text)) { return CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY; } if (/3\s*秒后|三秒后|\+\s*3|plus[-_\s]?3/i.test(text)) { return FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY; } if (/5\s*秒前|五秒前|-\s*5|minus[-_\s]?5/i.test(text)) { return FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY; } if (/多机位|九宫格|9\s*宫格|3\s*[x×]\s*3/.test(text)) { return MULTI_VIEW_NINE_GRID_FUNCTION_KEY; } return null; } function resolveOfficialImagePresetFunctionKey({ fallbackKey, option, title, }: { fallbackKey?: string; option?: unknown; title?: unknown; }) { const optionKey = getOfficialImagePresetFunctionKey(option); if (optionKey) return optionKey; const titleKey = getOfficialImagePresetFunctionKey(title); if (titleKey) return titleKey; const normalizedFallbackKey = String(fallbackKey || "").trim(); return normalizedFallbackKey || null; } function isCharacterThreeViewOption(value?: unknown) { return getOfficialImagePresetFunctionKey(value) === CHARACTER_THREE_VIEW_FUNCTION_KEY; } function getImageGenerationBillingFeature(functionKey: string) { if ( functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY || functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ) { return "multi-view" as const; } if (functionKey === "image-repaint") { return "repaint" as const; } return undefined; } function isWideImageGenerationFunction(functionKey: string) { return ( functionKey === STORY_BEAT_GRID_FUNCTION_KEY || functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY || functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY || functionKey === CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY || functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY || functionKey === FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY || functionKey === FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY ); } function getPresetGridLayout(functionKey: string) { if (functionKey === STORY_BEAT_GRID_FUNCTION_KEY) return { columns: 2, rows: 2 }; if (functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY) return { columns: 3, rows: 3 }; if (functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY) return { columns: 5, rows: 5 }; return null; } function isPresetGridFunction(functionKey: string) { return Boolean(getPresetGridLayout(functionKey)); } function getNodeParams(node: CloneNode | null | undefined) { const params = node?.draftValues?.params; return params && typeof params === "object" && !Array.isArray(params) ? (params as Record) : {}; } function getNodeFunctionKey(node: CloneNode | null | undefined) { const params = getNodeParams(node); return String(params.functionKey || node?.draftValues?.functionKey || "").trim(); } function isCharacterThreeViewNode(node: CloneNode | null | undefined) { if (!node || node.inputType !== "image") return false; return ( getNodeFunctionKey(node) === CHARACTER_THREE_VIEW_FUNCTION_KEY || isCharacterThreeViewOption(node?.title) ); } function getConfiguredFunction( config: VideoCanvasFunctionConfig, key: string, ): VideoCanvasFunctionDefinition { return config.functions[key] || defaultVideoCanvasFunctionConfig.functions[key]; } function getDefaultFunctionPrompt(key: string) { return defaultVideoCanvasFunctionConfig.functions[key]?.prompt || ""; } function normalizeModelRef(value: unknown) { const model = String(value || "").trim(); return model && model !== "auto" ? model : ""; } function getFunctionModel( config: VideoCanvasFunctionConfig, definition: VideoCanvasFunctionDefinition, overrideModel?: string, ) { const override = normalizeModelRef(overrideModel); if (override) return override; const fallback = definition.kind === "video" ? config.defaultVideoModel : definition.kind === "multimodal" ? config.defaultMultimodalModel : config.defaultImageModel; return normalizeModelRef(definition.model || fallback) || undefined; } function getFunctionModelValue( config: VideoCanvasFunctionConfig, definition: VideoCanvasFunctionDefinition, ) { const fallback = definition.kind === "video" ? config.defaultVideoModel : definition.kind === "multimodal" ? config.defaultMultimodalModel : config.defaultImageModel; return String(definition.model || fallback || "auto").trim() || "auto"; } function getCanvasModelRef(model: CanvasModelOption) { return String(model.modelId || model.id || "").trim(); } function isCanvasImageModel(model: CanvasModelOption) { return isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }); } function isCanvasVideoModel(model: CanvasModelOption) { return isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }); } function filterCanvasModelsByKind(models: CanvasModelOption[], kind: CanvasModelKind) { const usable = models.filter((model) => getCanvasModelRef(model)); let filtered = usable; if (kind === "image") { filtered = usable.filter(isCanvasImageModel); } else if (kind === "video") { filtered = usable.filter(isCanvasVideoModel); } else if (kind === "multimodal") { filtered = usable.filter( (model) => model.isMultimodal || model.supportsVision || (!isCanvasImageModel(model) && !isCanvasVideoModel(model)), ); } return filtered.length ? filtered : usable; } function getCanvasModelDisplayName( modelRef: string | undefined, models: CanvasModelOption[], fallback = "自动模型", ) { const normalized = normalizeModelRef(modelRef); if (!normalized) return fallback; const match = models.find( (model) => model.id === normalized || model.modelId === normalized || getCanvasModelRef(model) === normalized, ); return match?.displayName || normalized; } function composeFunctionPrompt( definition: VideoCanvasFunctionDefinition, userPrompt?: string, ) { const base = definition.prompt.trim(); const extra = String(userPrompt || "").trim(); if (!base) return extra; if (!extra || extra === base) return base; return `${base}\n\n用户补充:${extra}`; } function composeConfiguredImagePresetPrompt( definition: VideoCanvasFunctionDefinition, userPrompt?: string, ) { const base = definition.prompt.trim() || getDefaultFunctionPrompt(definition.key); const extra = String(userPrompt || "").trim(); const extraFunctionKey = getOfficialImagePresetFunctionKey(extra); if (!extra || extra === base || extra === definition.label || extraFunctionKey === definition.key) return base; if (!base) return extra; return `${base}\n\n用户补充:${extra}`; } function composeMultiViewNineGridPrompt( definition: VideoCanvasFunctionDefinition, option?: string, ) { return composeConfiguredImagePresetPrompt(definition, option); } function getFirstGeneratedImageUrl( response: Awaited>, ) { const first = response.data?.images?.[0]; return first?.originalUrl || first?.thumbnailUrl || ""; } function createEqualGridRects(rows: number, columns: number): GridCropRect[] { return Array.from({ length: rows * columns }).map((_, index) => { const column = index % columns; const row = Math.floor(index / columns); return { height: 1 / rows, width: 1 / columns, x: column / columns, y: row / rows, }; }); } function buildGridRectsFromLines({ columns, height, horizontalLines, rows, verticalLines, width, }: { columns: number; height: number; horizontalLines: number[]; rows: number; verticalLines: number[]; width: number; }): GridCropRect[] { const lineMargin = Math.max(1, Math.round(Math.min(width, height) * 0.0015)); const xBounds = [0, ...verticalLines, width]; const yBounds = [0, ...horizontalLines, height]; const rects: GridCropRect[] = []; for (let row = 0; row < rows; row += 1) { for (let column = 0; column < columns; column += 1) { const left = column === 0 ? xBounds[column] : xBounds[column] + lineMargin; const right = column === columns - 1 ? xBounds[column + 1] : xBounds[column + 1] - lineMargin; const top = row === 0 ? yBounds[row] : yBounds[row] + lineMargin; const bottom = row === rows - 1 ? yBounds[row + 1] : yBounds[row + 1] - lineMargin; rects.push({ height: Math.max((bottom - top) / height, 0.02), width: Math.max((right - left) / width, 0.02), x: Math.max(left / width, 0), y: Math.max(top / height, 0), }); } } return rects; } function getLineScore(data: Uint8ClampedArray, width: number, height: number, axis: "x" | "y", index: number) { const limit = axis === "x" ? height : width; const step = Math.max(1, Math.floor(limit / 260)); let score = 0; let samples = 0; for (let point = 0; point < limit; point += step) { const x = axis === "x" ? index : point; const y = axis === "x" ? point : index; const offset = (y * width + x) * 4; const alpha = data[offset + 3] / 255; if (alpha < 0.1) continue; const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114; score += luminance < 96 ? ((96 - luminance) / 96) * alpha : 0; samples += 1; } return samples > 0 ? score / samples : 0; } function findGridDividerLines( data: Uint8ClampedArray, width: number, height: number, count: number, axis: "x" | "y" ) { const size = axis === "x" ? width : height; const otherSize = axis === "x" ? height : width; const lines: number[] = []; let detectedCount = 0; for (let divider = 1; divider < count; divider += 1) { const expected = (divider / count) * size; const searchRadius = Math.max(8, Math.round(size * 0.08)); const from = Math.max(1, Math.round(expected - searchRadius)); const to = Math.min(size - 2, Math.round(expected + searchRadius)); let bestIndex = Math.round(expected); let bestScore = 0; for (let index = from; index <= to; index += 1) { const score = getLineScore(data, width, height, axis, index - 1) * 0.25 + getLineScore(data, width, height, axis, index) * 0.5 + getLineScore(data, width, height, axis, index + 1) * 0.25; if (score > bestScore) { bestScore = score; bestIndex = index; } } const minimumScore = otherSize > 0 ? 0.18 : 0.22; if (bestScore >= minimumScore) { detectedCount += 1; lines.push(bestIndex); } else { lines.push(Math.round(expected)); } } return { detected: detectedCount === Math.max(0, count - 1), lines, }; } async function detectGridSplitFromImage(url: string, rows: number, columns: number): Promise { if (!url || typeof window === "undefined") return null; try { const image = await new Promise((resolve, reject) => { const nextImage = new Image(); if (!url.startsWith("blob:") && !url.startsWith("data:")) { nextImage.crossOrigin = "anonymous"; nextImage.referrerPolicy = "no-referrer"; } nextImage.onload = () => resolve(nextImage); nextImage.onerror = () => reject(new Error("image-load-failed")); nextImage.src = url; }); const naturalWidth = image.naturalWidth || image.width; const naturalHeight = image.naturalHeight || image.height; if (!naturalWidth || !naturalHeight) return null; const maxSide = 980; const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight)); const width = Math.max(1, Math.round(naturalWidth * scale)); const height = Math.max(1, Math.round(naturalHeight * scale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return null; context.drawImage(image, 0, 0, width, height); const imageData = context.getImageData(0, 0, width, height); const verticalResult = findGridDividerLines(imageData.data, width, height, columns, "x"); const horizontalResult = findGridDividerLines(imageData.data, width, height, rows, "y"); const rects = buildGridRectsFromLines({ columns, height, horizontalLines: horizontalResult.lines, rows, verticalLines: verticalResult.lines, width, }); return { columns, detected: verticalResult.detected && horizontalResult.detected, rects, rows, }; } catch (_error) { return null; } } 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 isOfficialImageControlNode(node: CloneNode | null | undefined): node is CloneNode { return !!node && (node.kind === "media" || node.kind === "script") && !!getPresetMode(node); } 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, fallbackIndex: number): CloneNode { const shouldUseOfficialSize = Boolean(node.draftValues?.manualEditing) || /^(文本|文本生成|文本节点)/.test(String(node.title || "")); const title = node.title && !/^(文本|文本生成|文本节点)(\s*\d+)?$/.test(node.title) ? node.title : `文本节点 ${extractTrailingNumber(node.title) || fallbackIndex}`; return { ...node, kind: "script", 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"); const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node); const isNineGridResultNode = !isCharacterThreeViewResultNode && (action === "IMAGE_GENERATE_NINE_GRID" || Boolean(node.draftValues?.nineGridComposite) || (node.inputType === "image" && /多机位九宫格/.test(node.title || ""))); if (action === "TEXT_RESOURCE" && !getPresetMode(node)) { return normalizeTextResourceNode(node, fallbackIndex); } if (isCharacterThreeViewResultNode) { return { ...node, width: OFFICIAL_NINE_GRID_WIDTH, height: OFFICIAL_NINE_GRID_HEIGHT, draftValues: { ...(node.draftValues || {}), action: "IMAGE_GENERATE", functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY, gridColumns: undefined, gridRows: undefined, nineGridCells: undefined, nineGridComposite: false, outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536", params: { ...getNodeParams(node), functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY, mode: "character-three-view", prompt: getDraftString(node, "prompt") || getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY), }, }, }; } if (isNineGridResultNode) { const params = getNodeParams(node); const gridFunctionKey = resolveOfficialImagePresetFunctionKey({ fallbackKey: getNodeFunctionKey(node), option: params.option || node.draftValues?.toolbarOption, title: node.title, }) || MULTI_VIEW_NINE_GRID_FUNCTION_KEY; const gridLayout = getPresetGridLayout(gridFunctionKey); return { ...node, width: OFFICIAL_NINE_GRID_WIDTH, height: OFFICIAL_NINE_GRID_HEIGHT, draftValues: { ...(node.draftValues || {}), action: "IMAGE_GENERATE_NINE_GRID", functionKey: gridFunctionKey, gridColumns: getDraftNumber(node, "gridColumns", gridLayout?.columns || 3), gridPrompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey), gridRows: getDraftNumber(node, "gridRows", gridLayout?.rows || 3), nineGridComposite: true, outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536", params: { ...params, functionKey: gridFunctionKey, mode: "grid", prompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey), }, }, }; } 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_FUNCTION_KEY; const distance = 100; const prompt = getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY); 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 rightBase = createPresetMediaNode({ action: "IMAGE_GENERATE", generated: true, height: 520, mediaType: "image", mediaUrl: CHARACTER_THREE_VIEW_URL, preset, prompt, title: "角色三视图生成", width: OFFICIAL_NINE_GRID_WIDTH, x: Math.round(center.x + distance / 2), y: Math.round(center.y - 320), }); const right: CloneNode = { ...rightBase, draftValues: { ...(rightBase.draftValues || {}), functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY, params: { ...getNodeParams(rightBase), functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY, mode: "character-three-view", prompt, }, }, }; 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 - 900); const leftY = Math.round(center.y - 370); const audio = createPresetMediaNode({ action: "AUDIO_RESOURCE", height: 190, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, preset, title: "音频", width: 720, x: leftX, y: leftY, }); const image = createPresetMediaNode({ action: "IMAGE_RESOURCE", height: 400, mediaType: "image", mediaUrl: AUDIO_REFERENCE_IMAGE_URL, preset, title: "图片", width: 720, x: leftX, y: leftY + 300, }); const right = createPresetMediaNode({ action: "VIDEO_GENERATE", generated: true, height: 430, mediaType: "video", preset, prompt: AUDIO_TO_VIDEO_PROMPT, title: "视频", width: 760, x: Math.round(center.x + 90), y: Math.round(center.y - 300), }); 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 { data: siteBrandSettings } = useSWR("/api/site-settings", fetcher); const { data: videoCanvasFunctionSettings } = useSWR<{ config?: VideoCanvasFunctionConfig }>( "/api/video-canvas/config", fetcher ); const { data: canvasModelsData } = useSWR<{ models?: CanvasModelOption[] }>( "/api/models", fetcher ); const videoCanvasFunctionConfig = useMemo( () => normalizeVideoCanvasFunctionConfig(videoCanvasFunctionSettings?.config), [videoCanvasFunctionSettings?.config] ); const canvasModelOptions = useMemo( () => (canvasModelsData?.models || []).filter((model) => getCanvasModelRef(model)), [canvasModelsData?.models] ); 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 [angleEditorNodeId, setAngleEditorNodeId] = useState(null); const [inspectorNodeId, setInspectorNodeId] = useState(null); const [dockOverviewOpen, setDockOverviewOpen] = useState(false); const [snapEnabled, setSnapEnabled] = useState(true); 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 inspectorNode = useMemo( () => nodes.find((node) => node.id === inspectorNodeId) || null, [inspectorNodeId, nodes] ); const connectionNode = useMemo( () => nodes.find((node) => node.id === connectionMenu?.nodeId) || null, [connectionMenu?.nodeId, nodes] ); const angleEditorNode = useMemo( () => nodes.find((node) => node.id === angleEditorNodeId) || null, [angleEditorNodeId, 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); }, []); useEffect(() => { const timer = window.setInterval(() => { setNodes((current) => { let changed = false; const nextNodes = current.map((node) => { if (getDraftString(node, "generationStatus") !== "pending") return node; const currentProgress = getDraftNumber(node, "generationProgress", 2); const step = currentProgress < 24 ? 2 : currentProgress < 68 ? 1 : 0.35; const nextProgress = Math.min(96, Number((currentProgress + step).toFixed(1))); if (nextProgress === currentProgress) return node; changed = true; return { ...node, draftValues: { ...(node.draftValues || {}), generationProgress: nextProgress, }, }; }); return changed ? nextNodes : current; }); }, 850); return () => window.clearInterval(timer); }, []); const cancelGenerationNode = 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)); toast.success("已取消生成"); }, []); const addWorkflowPreset = useCallback( (workflow: WorkflowPreset, index = 0) => { const title = String(workflow.name || workflow.title || "工具箱预设"); const baseX = 360 + index * 36; const baseY = 300 + index * 24; const reference = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: 280, mediaType: "image", mediaUrl: workflow.cover || CHARACTER_IMAGE_URL, title: "参考图", width: 420, x: baseX, y: baseY, }); const result = createMediaResourceNode({ action: "VIDEO_GENERATE", height: 315, mediaType: "video", mediaUrl: "", prompt: workflow.description || `${title} 预设工作流`, title, width: 560, x: baseX + 520, y: baseY - 18, }); const presetNode: CloneNode = { ...result, subtitle: "工具箱工作流", draftValues: { ...(result.draftValues || {}), generated: true, params: { prompt: workflow.description || `${title} 预设工作流`, presetId: workflow.uuid || workflow.id || "", presetName: title, sourceNodeId: reference.id, }, sourceNodeId: reference.id, workflow, }, }; addNodes([reference, presetNode], [{ id: makeId("edge"), source: reference.id, target: presetNode.id }]); setActivePanel(null); setConnectionMenu(null); toast.success(`已添加「${title}」工具箱工作流`); }, [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); setInspectorNodeId(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); setInspectorNodeId(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); setInspectorNodeId(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 "自己编写内容": { const textNode = createTextResourceNode({ content: "", title: `文本节点 ${nodes.length + nextNodes.length + 1}`, x: rightX, y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT), }); connectFromAnchor(textNode); 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: 720, height: 190, inputType: "audio", }; draftUpdates = { action: "AUDIO_RESOURCE", generated: false, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, nodeType: "audio", url: [AUDIO_SOURCE_URL], }; const video = { ...makeGenerated("video", "视频", Math.round(anchor.x + 720 + OFFICIAL_NODE_GAP), { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }), width: 760, height: 430, y: Math.round(anchor.y + 70), }; const image = createMediaResourceNode({ mediaType: "image", mediaUrl: AUDIO_REFERENCE_IMAGE_URL, title: "图片", width: 720, height: 400, x: anchor.x, y: Math.round(anchor.y + 300), }); 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( async (nodeId: string, action: ImageToolbarAction, option?: string) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const sourceUrl = getNodeSourceMediaUrl(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; } if (action === "orbit") { setSelectedNodeId(anchor.id); setAngleEditorNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); 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 { columns, rows, tileCount } = normalizeGridOption(option); const tileSize = tileCount === 25 ? 142 : tileCount === 16 ? 156 : 180; const gap = 26; const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2); const anchorMediaUrl = getNodeMediaUrl(anchor); const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite) && !anchorMediaUrl; const detectedSplit = !isSyntheticGrid && anchorMediaUrl ? await detectGridSplitFromImage(anchorMediaUrl, rows, columns) : null; const rects = detectedSplit?.rects && detectedSplit.rects.length >= tileCount ? detectedSplit.rects : createEqualGridRects(rows, columns); const sourceVisuals = Array.isArray(anchor.draftValues?.nineGridCells) ? (anchor.draftValues?.nineGridCells as GridCellVisual[]) : []; Array.from({ length: tileCount }).forEach((_, index) => { const tile = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: tileSize, mediaType: "image", mediaUrl: isSyntheticGrid ? sourceUrl : anchorMediaUrl || sourceUrl, prompt: option || "宫格切分", title: `镜头 ${String(index + 1).padStart(2, "0")}`, width: tileSize, x: rightX + (index % columns) * (tileSize + gap), y: startY + Math.floor(index / columns) * (tileSize + gap), }); nextNodes.push({ ...tile, subtitle: "宫格切片", draftValues: { ...(tile.draftValues || {}), cropTile: { columns, detectedDivider: Boolean(detectedSplit?.detected), index, rect: rects[index], rows, sourceCompositeNodeId: anchor.id, sourceMode: isSyntheticGrid ? "synthetic-grid" : "detected-lines", }, gridCellVisual: isSyntheticGrid ? sourceVisuals[index] || getGridCellVisual(index) : undefined, sourceNodeId: anchor.id, sourceUrl, }, }); nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id }); }); setNodes((current) => [...current, ...nextNodes]); setEdges((current) => [...current, ...nextEdges]); setSelectedNodeId(nextNodes[0]?.id || anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success( detectedSplit?.detected ? `已识别黑色分割线并切分为 ${tileCount} 张图片` : `已按 ${columns}x${rows} 宫格切分为 ${tileCount} 张图片` ); return; } const labels: Record, string> = { grid: "九宫格", hd: "高清", lighting: "打光", panorama: "720 全景", }; const title = labels[action]; if (action === "grid") { const functionKey = getOfficialImagePresetFunctionKey(option) || MULTI_VIEW_NINE_GRID_FUNCTION_KEY; const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const prompt = functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? composeMultiViewNineGridPrompt(definition, option) : composeConfiguredImagePresetPrompt(definition, option); const shouldGenerate = videoCanvasFunctionConfig.enabled && definition.enabled; const resultTitle = option?.trim() || definition.label || title; const gridLayout = getPresetGridLayout(functionKey); if (gridLayout) { const gridNode = createNineGridCompositeNode({ anchor, columns: gridLayout.columns, functionKey, gridPrompt: prompt, option: resultTitle, rows: gridLayout.rows, sourceUrl, x: rightX, y: centerY(OFFICIAL_NINE_GRID_HEIGHT), }); const pendingGridNode: CloneNode = { ...gridNode, draftValues: { ...(gridNode.draftValues || {}), functionKey, generated: !shouldGenerate, generationProgress: shouldGenerate ? 2 : 100, generationStatus: shouldGenerate ? "pending" : "complete", gridPrompt: prompt, mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"), params: { ...getNodeParams(gridNode), functionKey, mode: "grid", option: resultTitle, prompt, sourceNodeId: anchor.id, sourceUrl, }, pendingLabel: "生成中", prompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: resultTitle, url: shouldGenerate ? [] : gridNode.draftValues?.url, }, }; setNodes((current) => [...current, pendingGridNode]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]); setSelectedNodeId(pendingGridNode.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); if (shouldGenerate) { const response = await imageGenerationService.generateImage({ aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: 1, model: getFunctionModel(videoCanvasFunctionConfig, definition), prompt, promptExtend: false, referenceImage: sourceUrl, }); const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (generatedUrl) { setNodes((current) => current.map((node) => node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), functionKey, generated: true, generationProgress: 100, generationStatus: "complete", gridPrompt: prompt, mediaUrl: generatedUrl, params: { ...getNodeParams(node), functionKey, mode: "grid", option: resultTitle, prompt, }, prompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: resultTitle, url: [generatedUrl], }, } : node ) ); } else { const errorMessage = response.error || `${definition.label}生成未返回结果`; setNodes((current) => current.map((node) => node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), generationStatus: "failed", generationError: errorMessage, }, } : node ) ); toast.error(`${errorMessage},已保留占位节点`); } } toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`); return; } const resultNode = createMediaResourceNode({ action: "IMAGE_GENERATE", height: OFFICIAL_NINE_GRID_HEIGHT, mediaType: "image", mediaUrl: "", prompt, title: resultTitle, width: OFFICIAL_NINE_GRID_WIDTH, x: rightX, y: centerY(OFFICIAL_NINE_GRID_HEIGHT), }); const pendingNode: CloneNode = { ...resultNode, subtitle: shouldGenerate ? "生成中" : "生成结果", draftValues: { ...(resultNode.draftValues || {}), action: "IMAGE_GENERATE", functionKey, generated: false, generationProgress: shouldGenerate ? 2 : 0, generationStatus: shouldGenerate ? "pending" : "idle", mediaType: "image", mediaUrl: "", nodeType: "image", outputSizeLabel: "2752 × 1536", params: { functionKey, mode: functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ? "character-three-view" : "image", option: resultTitle, prompt, sourceNodeId: anchor.id, sourceUrl, }, pendingLabel: "生成中", prompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: resultTitle, url: [], }, }; setNodes((current) => [...current, pendingNode]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]); setSelectedNodeId(pendingNode.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); if (shouldGenerate) { const response = await imageGenerationService.generateImage({ aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: 1, model: getFunctionModel(videoCanvasFunctionConfig, definition), prompt, promptExtend: false, referenceImage: sourceUrl, }); const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (generatedUrl) { setNodes((current) => current.map((node) => node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, subtitle: "生成结果", draftValues: { ...(node.draftValues || {}), functionKey, generated: true, generationProgress: 100, generationStatus: "complete", mediaUrl: generatedUrl, params: { ...getNodeParams(node), functionKey, option: resultTitle, prompt, }, prompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: resultTitle, url: [generatedUrl], }, } : node ) ); } else { const errorMessage = response.error || `${definition.label}生成未返回结果`; setNodes((current) => current.map((node) => node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), generationStatus: "failed", generationError: errorMessage, }, } : node ) ); toast.error(`${errorMessage},已保留占位节点`); } } toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`); return; } const functionKey = action === "lighting" ? "lighting" : action === "panorama" ? "panorama-720" : /扩/.test(option || "") ? "image-outpaint" : /重绘/.test(option || "") ? "image-repaint" : /擦/.test(option || "") ? "image-erase" : /抠/.test(option || "") ? "remove-bg" : "hd-upscale"; const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const prompt = composeFunctionPrompt(definition, option?.trim() || title); if (videoCanvasFunctionConfig.enabled && definition?.enabled) { const pendingResultNode = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: 280, mediaType: "image", mediaUrl: "", prompt, title: definition.label || title, width: 420, x: rightX, y: centerY(280), }); const pendingNode: CloneNode = { ...pendingResultNode, draftValues: { ...(pendingResultNode.draftValues || {}), generated: false, generationProgress: 2, generationStatus: "pending", params: { mode: action, prompt, sourceNodeId: anchor.id, sourceUrl, }, pendingLabel: "生成中", sourceNodeId: anchor.id, sourceUrl, toolbarOption: option || "", url: [], }, }; setNodes((current) => [...current, pendingNode]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]); setSelectedNodeId(pendingNode.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); let generatedUrl = ""; if (definition.route === "enhance") { const response = await imageGenerationService.enhanceImage(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) toast.error(response.error || "高清处理失败"); } else if (definition.route === "extend") { const response = await imageGenerationService.extendImage(sourceUrl, { prompt }); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) toast.error(response.error || "扩图处理失败"); } else if (definition.route === "remove-bg") { const response = await imageGenerationService.removeBackground(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) toast.error(response.error || "抠图处理失败"); } else if (definition.route === "canvas-generate-image") { const response = await imageGenerationService.generateImage({ aspectRatio: "16:9", billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: 1, model: getFunctionModel(videoCanvasFunctionConfig, definition), prompt, promptExtend: false, referenceImage: sourceUrl, }); generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (!generatedUrl && !response.success) toast.error(response.error || `${definition.label}处理失败`); } if (generatedUrl) { setNodes((current) => current.map((node) => node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaUrl: generatedUrl, params: { mode: action, prompt, sourceNodeId: anchor.id, sourceUrl, }, sourceNodeId: anchor.id, sourceUrl, toolbarOption: option || "", url: [generatedUrl], }, } : node ) ); toast.success(`已生成「${definition.label || title}」结果`); } else { setNodes((current) => current.map((node) => node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), generationStatus: "failed", generationError: `${definition.label || title}处理失败`, }, } : node ) ); } return; } const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); 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, videoCanvasFunctionConfig] ); 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( async (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 starterMode = getPresetMode(anchor); const slashScene = payload.slashScene || ""; const promptInput = payload.prompt.trim(); const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene); const functionKey = slashFunctionKey || (starterMode === CHARACTER_THREE_VIEW_FUNCTION_KEY ? CHARACTER_THREE_VIEW_FUNCTION_KEY : "image-repaint"); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const isWideGeneration = isWideImageGenerationFunction(functionKey); const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : 420; const resultHeight = isWideGeneration ? OFFICIAL_NINE_GRID_HEIGHT : 280; const resultTitle = slashScene || definition.label || "图片生成"; const gridLayout = getPresetGridLayout(functionKey); const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1)); const finalPrompt = functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? composeMultiViewNineGridPrompt(definition, promptInput || slashScene || "多机位九宫格") : isWideGeneration ? composeConfiguredImagePresetPrompt(definition, promptInput || slashScene || definition.label) : composeFunctionPrompt( definition, promptInput || slashScene || "生成角色三视图", ); let generatedUrl = ""; if (videoCanvasFunctionConfig.enabled && definition?.enabled) { const response = await imageGenerationService.generateImage({ aspectRatio: isWideGeneration ? "16:9" : payload.settings.ratio, billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: isWideGeneration ? 1 : count, model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model), prompt: finalPrompt, promptExtend: !isWideGeneration, referenceImage: sourceUrl, }); generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (!generatedUrl && !response.success) { toast.error(response.error || `${definition.label}生成失败,已保留生成节点`); } } const generatedNode = generatedUrl ? createMediaResourceNode({ action: "IMAGE_RESOURCE", height: resultHeight, mediaType: "image", mediaUrl: generatedUrl, prompt: finalPrompt, title: resultTitle, width: resultWidth, x: rightX, y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2), }) : null; const placeholderNode = isWideGeneration ? createMediaResourceNode({ action: isPresetGridFunction(functionKey) ? "IMAGE_GENERATE_NINE_GRID" : "IMAGE_GENERATE", height: resultHeight, mediaType: "image", mediaUrl: "", prompt: finalPrompt, title: resultTitle, width: resultWidth, x: rightX, y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2), }) : createTargetModuleNode("image", rightX, 0, nodes.length + 1); const created = generatedNode || placeholderNode; const resultNode: CloneNode = { ...created, title: resultTitle, y: generatedNode?.y ?? (isWideGeneration ? placeholderNode.y : Math.round(anchor.y + anchor.height / 2 - created.height / 2)), draftValues: { ...(created.draftValues || {}), functionKey, generated: Boolean(generatedUrl) || created.draftValues?.generated, generationStatus: generatedUrl ? "complete" : created.draftValues?.generationStatus, gridColumns: gridLayout?.columns || created.draftValues?.gridColumns, gridPrompt: gridLayout ? finalPrompt : created.draftValues?.gridPrompt, gridRows: gridLayout?.rows || created.draftValues?.gridRows, nineGridCells: gridLayout ? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index)) : created.draftValues?.nineGridCells, nineGridComposite: gridLayout ? true : created.draftValues?.nineGridComposite, outputSizeLabel: isWideGeneration ? "2752 × 1536" : created.draftValues?.outputSizeLabel, params: { functionKey, mode: functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ? "character-three-view" : isPresetGridFunction(functionKey) ? "grid" : "image", prompt: finalPrompt, references: payload.references, settings: payload.settings, slashScene, sourceNodeId: anchor.id, sourceUrl, }, prompt: finalPrompt, sourceNodeId: anchor.id, sourceUrl, targetPreset: starterMode || undefined, toolbarOption: slashScene, }, }; 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(generatedUrl ? "已按当前提示词生成图片结果" : "已按当前提示词创建图片生成节点"); }, [nodes, videoCanvasFunctionConfig] ); const runPresetComposerSubmit = useCallback( async ( nodeId: string, mode: Exclude, draftPrompt: string, settings?: ComposerSettings, ) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const incomingNodes = edges .filter((edge) => edge.target === nodeId) .map((edge) => nodes.find((item) => item.id === edge.source)) .filter((item): item is CloneNode => Boolean(item)); const imageUrls = incomingNodes .filter((item) => item.inputType === "image") .map((item) => getNodeSourceMediaUrl(item)) .filter(Boolean); const videoUrls = incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeSourceMediaUrl(item)) .filter(Boolean); try { if (mode === "story-script") { const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script"); const textInputs = incomingNodes .flatMap((item) => { const content = item.draftValues?.content; return Array.isArray(content) ? content.filter((value): value is string => typeof value === "string") : []; }) .join("\n\n"); const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim()); const response = await workflowAnalysisService.analyze({ imageUrls, mode: "agent-plan", nodeTitle: node.title, prompt, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, settings?.model), videoUrls, }); if (!response.success) throw new Error(response.error || "故事脚本生成失败"); const content = response.data?.raw || response.data?.agentPlan?.join("\n") || response.data?.summary || ""; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), content: content ? [content] : item.draftValues?.content, generated: true, prompt, rows: response.data?.shotIdeas || item.draftValues?.rows, }, } : item ) ); toast.success("故事脚本已生成"); return; } const definition = getConfiguredFunction( videoCanvasFunctionConfig, mode === "audio-to-video" ? "audio-to-video" : "first-frame-video", ); const prompt = composeFunctionPrompt(definition, draftPrompt); const response = await videoGenerationService.generateVideo({ images: imageUrls.slice(0, 4), preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, settings?.model), prompt, ratio: "16:9", resolution: "720p", }); if (!response.success || !response.data?.url) { throw new Error(response.error || `${definition.label}生成失败`); } setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), generated: true, mediaType: "video", mediaUrl: response.data?.url, nodeType: "video", prompt, url: [response.data?.url], videoMeta: response.data?.metadata || null, }, } : item ) ); toast.success(`${definition.label}已生成`); } catch (error) { toast.error(error instanceof Error ? error.message : "生成失败"); } }, [edges, nodes, videoCanvasFunctionConfig] ); 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); setInspectorNodeId(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(async (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; setSelectedNodeId(nodeId); setNodeContextMenu(null); setConnectionMenu(null); const incomingNodes = edges .filter((edge) => edge.target === nodeId) .map((edge) => nodes.find((item) => item.id === edge.source)) .filter((item): item is CloneNode => Boolean(item)); const imageUrls = incomingNodes .filter((item) => item.inputType === "image") .map((item) => getNodeSourceMediaUrl(item)) .filter(Boolean); const videoUrls = incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeSourceMediaUrl(item)) .filter(Boolean); const action = String(node.draftValues?.action || ""); const params = (node.draftValues?.params || {}) as Record; const paramsSettings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const nodeModelRef = normalizeModelRef(params.model || paramsSettings.model); const rawPrompt = String(params.prompt || node.draftValues?.prompt || ""); try { if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) { const savedFunctionKey = String(params.functionKey || node.draftValues?.functionKey || "").trim(); const explicitFunctionKey = resolveOfficialImagePresetFunctionKey({ fallbackKey: savedFunctionKey, option: params.option || node.draftValues?.toolbarOption, title: node.title, }) || savedFunctionKey; const functionKey = explicitFunctionKey && (videoCanvasFunctionConfig.functions[explicitFunctionKey] || defaultVideoCanvasFunctionConfig.functions[explicitFunctionKey]) ? explicitFunctionKey : action === "IMAGE_GENERATE_NINE_GRID" ? MULTI_VIEW_NINE_GRID_FUNCTION_KEY : node.draftValues?.generatorType === "ENHANCE" ? "hd-upscale" : "image-repaint"; const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const sourceUrl = imageUrls[0] || getNodeSourceMediaUrl(node); const prompt = functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? composeMultiViewNineGridPrompt(definition, String(params.option || node.title)) : isWideImageGenerationFunction(functionKey) ? composeConfiguredImagePresetPrompt(definition, rawPrompt || String(params.option || node.title)) : composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title)); const gridLayout = getPresetGridLayout(functionKey); let generatedUrl = ""; if (definition.route === "enhance" && sourceUrl) { const response = await imageGenerationService.enhanceImage(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败"); } else { const response = await imageGenerationService.generateImage({ aspectRatio: isWideImageGenerationFunction(functionKey) ? "16:9" : undefined, billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: 1, model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef), prompt, promptExtend: !isWideImageGenerationFunction(functionKey), referenceImage: sourceUrl || undefined, }); generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "图片生成失败"); } if (!generatedUrl) throw new Error("图片生成未返回结果"); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), functionKey, generated: true, generationProgress: 100, generationStatus: "complete", gridColumns: gridLayout?.columns || item.draftValues?.gridColumns, gridPrompt: gridLayout ? prompt : item.draftValues?.gridPrompt, gridRows: gridLayout?.rows || item.draftValues?.gridRows, nineGridCells: gridLayout ? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index)) : item.draftValues?.nineGridCells, nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite, mediaType: "image", mediaUrl: generatedUrl, nodeType: "image", outputSizeLabel: isWideImageGenerationFunction(functionKey) ? "2752 × 1536" : item.draftValues?.outputSizeLabel, params: { ...getNodeParams(item), functionKey, prompt, }, prompt, sourceUrl, url: [generatedUrl], }, } : item ) ); toast.success(`${node.title} 已生成图片结果`); return; } if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) { const definition = getConfiguredFunction( videoCanvasFunctionConfig, imageUrls.length >= 2 ? "first-last-frame-video" : imageUrls.length === 1 ? "first-frame-video" : "text-to-video", ); const prompt = composeFunctionPrompt(definition, rawPrompt || node.title); const response = await videoGenerationService.generateVideo({ images: imageUrls.slice(0, 4), preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef), prompt, ratio: "16:9", resolution: "720p", }); if (!response.success || !response.data?.url) { throw new Error(response.error || "视频生成失败"); } setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), generated: true, mediaType: "video", mediaUrl: response.data?.url, nodeType: "video", prompt, url: [response.data?.url], videoMeta: response.data?.metadata || null, }, } : item ) ); toast.success(`${node.title} 已生成视频结果`); return; } if (node.inputType === "text" || /SCRIPT_GENERATE|TEXT_GENERATE/.test(action)) { const definition = getConfiguredFunction( videoCanvasFunctionConfig, imageUrls.length > 0 || videoUrls.length > 0 ? "image-to-prompt" : "story-script", ); const prompt = composeFunctionPrompt(definition, rawPrompt || STORY_SCRIPT_PROMPT); const response = await workflowAnalysisService.analyze({ imageUrls, mode: "agent-plan", nodeTitle: node.title, prompt, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef), videoUrls, }); if (!response.success) throw new Error(response.error || "多模态分析失败"); const content = response.data?.raw || response.data?.agentPlan?.join("\n") || response.data?.summary || response.data?.recommendedNextSteps?.join("\n") || ""; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), content: content ? [content] : item.draftValues?.content, generated: true, prompt, rows: response.data?.shotIdeas || item.draftValues?.rows, }, } : item ) ); toast.success(`${node.title} 已生成文本结果`); return; } toast.info(`${node.title} 暂无可执行的后端动作`); } catch (error) { toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`); } }, [edges, nodes, videoCanvasFunctionConfig]); 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)); setInspectorNodeId((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 === inspectorNodeId ? { ...node, draftValues: { ...(node.draftValues || {}), [key]: value } } : node ) ); }, [inspectorNodeId]); const startNodeDrag = useCallback((event: ReactPointerEvent, node: CloneNode) => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setSelectedNodeId(node.id); setInspectorNodeId(null); 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); setInspectorNodeId(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; const gridSize = snapEnabled ? 8 : 1; const nextX = Math.round((drag.nodeX + dx) / gridSize) * gridSize; const nextY = Math.round((drag.nodeY + dy) / gridSize) * gridSize; setNodes((current) => current.map((node) => node.id === drag.id ? { ...node, x: nextX, y: nextY } : node ) ); return; } setViewport((current) => ({ ...current, x: Math.round(drag.viewportX + event.clientX - drag.startX), y: Math.round(drag.viewportY + event.clientY - drag.startY), })); }, [snapEnabled, 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 fitNodesToView = useCallback( (targetNodes: CloneNode[]) => { if (targetNodes.length === 0) { setViewport(DEFAULT_VIEWPORT); return; } const canvasWidth = canvasRef.current?.clientWidth || window.innerWidth; const canvasHeight = canvasRef.current?.clientHeight || window.innerHeight; const minX = Math.min(...targetNodes.map((node) => node.x)); const minY = Math.min(...targetNodes.map((node) => node.y)); const maxX = Math.max(...targetNodes.map((node) => node.x + node.width)); const maxY = Math.max(...targetNodes.map((node) => node.y + node.height)); const boundsWidth = Math.max(1, maxX - minX); const boundsHeight = Math.max(1, maxY - minY); const padding = targetNodes.length === 1 ? 280 : 220; const nextZoom = Math.min( 1.35, Math.max(0.3, Math.min((canvasWidth - padding) / boundsWidth, (canvasHeight - padding) / boundsHeight)) ); setViewport({ x: Math.round((canvasWidth - boundsWidth * nextZoom) / 2 - minX * nextZoom), y: Math.round((canvasHeight - boundsHeight * nextZoom) / 2 - minY * nextZoom), zoom: Number(nextZoom.toFixed(2)), }); }, [] ); const focusDockTarget = useCallback(() => { const target = selectedNode ? [selectedNode] : nodes; fitNodesToView(target); setDockOverviewOpen(false); }, [fitNodesToView, nodes, selectedNode]); 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); setInspectorNodeId(null); setActivePanel((current) => (current === panel ? null : panel)); }, []); const isBusy = isLoading || isProjectLoading; const selectedPresetControlNode = isOfficialImageControlNode(selectedNode) ? selectedNode : null; const selectedPresetMode = getPresetMode(selectedPresetControlNode); const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null; const selectedTextResourceNode = selectedNode && getDraftString(selectedNode, "action") === "TEXT_RESOURCE" ? 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), zoom: viewport.zoom, }; }, [selectedPresetControlNode, viewport.x, viewport.y, viewport.zoom]); const selectedTargetAnchor = useMemo(() => { if (!selectedNode || !selectedTargetModuleKind) return null; return { bottomY: Math.round(viewport.y + (selectedNode.y + selectedNode.height) * viewport.zoom), centerX: Math.round(viewport.x + (selectedNode.x + selectedNode.width / 2) * viewport.zoom), topY: Math.round(viewport.y + selectedNode.y * viewport.zoom), zoom: viewport.zoom, }; }, [selectedNode, selectedTargetModuleKind, viewport.x, viewport.y, viewport.zoom]); return (
{nodes.map((node) => ( openNodeContext(event, node)} onCancelGeneration={() => cancelGenerationNode(node.id)} 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} {selectedPresetAnchor && selectedPresetControlNode ? ( submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} onPresetSubmit={(prompt, settings) => runPresetComposerSubmit( selectedPresetControlNode.id, selectedPresetMode ?? "character-three-view", prompt, settings ) } selectedNode={selectedPresetControlNode} /> ) : null} {selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? ( { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: { ...(node.draftValues || {}), content: prompt ? [prompt] : [], manualEditing: true, manualTextComposer: true, params: { ...((node.draftValues?.params as Record | undefined) || {}), model, prompt, }, }, } : node ) ); toast.success("文本内容已写入当前模块"); }} /> ) : null} toggleTopPanel("account")} onCommitTitle={commitTitle} onDraftTitleChange={setDraftTitle} onStartTitleEdit={() => setIsTitleEditing(true)} projectTitle={projectTitle} routerBack={() => router.push("/")} siteLogo={siteBrandSettings?.siteLogo} siteLogoDark={siteBrandSettings?.siteLogoDark} siteName={siteBrandSettings?.siteName} /> { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel((current) => current === panel ? null : panel); }} /> setDockOverviewOpen((value) => !value)} onToggleSnap={() => setSnapEnabled((value) => !value)} onZoomIn={() => zoomBy(0.1)} onZoomOut={() => zoomBy(-0.1)} /> {activePanel === "add" ? ( setActivePanel(null)} onGallerySelect={() => setActivePanel("assets")} onUploadFiles={addUploadedFiles} /> ) : null} {activePanel === "workflow" ? ( setActivePanel(null)} /> ) : null} {inspectorNode ? ( setInspectorNodeId(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} {angleEditorNode ? ( { const angleDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "angle-edit"); const sourceUrl = getNodeMediaUrl(angleEditorNode) || getNodeSourceMediaUrl(angleEditorNode); let generatedUrl = ""; if (sourceUrl && videoCanvasFunctionConfig.enabled && angleDefinition?.enabled) { const response = await imageGenerationService.editAngle({ horizontalAngle: settings.horizontalAngle, imageUrl: sourceUrl, verticalAngle: settings.verticalAngle, zoom: settings.zoom, }); generatedUrl = response.success ? response.data?.url || response.data?.thumbnailUrl || "" : ""; if (!generatedUrl && !response.success) { toast.error(response.error || "多角度生成失败,已保留参数"); } } setNodes((current) => current.map((node) => node.id === angleEditorNode.id ? { ...node, draftValues: { ...(node.draftValues || {}), angleSettings: settings, params: { ...((node.draftValues?.params as Record | undefined) || {}), horizontal_angle: settings.horizontalAngle, prompt: settings.promptEnabled ? "按自定义多角度参数生成视角变化" : "", vertical_angle: settings.verticalAngle, zoom: settings.zoom, }, }, } : node ) ); if (generatedUrl) { const resultNode = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: 280, mediaType: "image", mediaUrl: generatedUrl, prompt: angleDefinition.prompt, title: "多角度结果", width: 420, x: Math.round(angleEditorNode.x + angleEditorNode.width + OFFICIAL_NODE_GAP), y: Math.round(angleEditorNode.y + angleEditorNode.height / 2 - 140), }); setNodes((current) => [ ...current, { ...resultNode, draftValues: { ...(resultNode.draftValues || {}), angleSettings: settings, generated: true, sourceNodeId: angleEditorNode.id, sourceUrl, }, }, ]); setEdges((current) => [ ...current, { id: makeId("edge"), source: angleEditorNode.id, target: resultNode.id }, ]); setSelectedNodeId(resultNode.id); } setAngleEditorNodeId(null); toast.success(generatedUrl ? "已按多角度参数生成结果" : "已应用多角度参数"); }} onClose={() => setAngleEditorNodeId(null)} /> ) : null} {isBusy ? (
项目加载中...
) : null}
); } function TopBar({ draftTitle, isTitleEditing, onAccount, onCommitTitle, onDraftTitleChange, onStartTitleEdit, projectTitle, routerBack, siteLogo, siteLogoDark, siteName, }: { draftTitle: string; isTitleEditing: boolean; onAccount: () => void; onCommitTitle: () => void; onDraftTitleChange: (value: string) => void; onStartTitleEdit: () => void; projectTitle: string; routerBack: () => void; siteLogo?: string | null; siteLogoDark?: string | null; siteName?: string | null; }) { const { data: membershipSummary } = useSWR("/api/user/membership-summary", fetcher); const credits = Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0)); return (
{isTitleEditing ? ( onDraftTitleChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") onCommitTitle(); }} value={draftTitle} /> ) : ( )}
); } function formatCreditValue(value: number) { if (!Number.isFinite(value)) return "0"; return Math.round(value).toLocaleString("zh-CN"); } function LeftToolbar({ activePanel, onOpen, }: { activePanel: ActivePanel; onOpen: (panel: Exclude) => void; }) { return ( ); } function ZoomDock({ compact = false, nodes = [], onCenter, onToggleOverview, onToggleSnap, overviewOpen = false, selectedNodeId, snapEnabled = true, onZoomIn, onZoomOut, zoom, }: { compact?: boolean; nodes?: CloneNode[]; onCenter: () => void; onToggleOverview?: () => void; onToggleSnap?: () => void; overviewOpen?: boolean; selectedNodeId?: string | null; snapEnabled?: boolean; onZoomIn: () => void; onZoomOut: () => void; zoom: number; }) { if (compact) { return (
); } return (
{overviewOpen ? ( ) : null}
); } function ZoomDockButton({ active, children, label, onClick, selectedOutline, }: { active?: boolean; children: ReactNode; label: string; onClick?: () => void; selectedOutline?: boolean; }) { return ( ); } function CanvasOverviewPopover({ nodes, selectedNodeId }: { nodes: CloneNode[]; selectedNodeId: string | null }) { const bounds = useMemo(() => { if (nodes.length === 0) return null; const minX = Math.min(...nodes.map((node) => node.x)); const minY = Math.min(...nodes.map((node) => node.y)); const maxX = Math.max(...nodes.map((node) => node.x + node.width)); const maxY = Math.max(...nodes.map((node) => node.y + node.height)); return { minX, minY, maxX, maxY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }; }, [nodes]); const scale = bounds ? Math.min(188 / bounds.width, 112 / bounds.height) : 1; return (
画布总览 {nodes.length} 个节点
{bounds ? ( nodes.map((node) => ( )) ) : (
暂无节点
)}
); } function StylizedBrandLogo({ siteLogo, siteLogoDark, siteName, }: { siteLogo?: string | null; siteLogoDark?: string | null; siteName?: string | null; }) { const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot"; const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || ""; const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim()); return ( {displayLogo ? ( {resolvedSiteName} ) : ( )} {resolvedSiteName} ); } function PointerBadge() { return ( ); } function ArrowGlyph() { return ( ); } function MusicStarterIcon() { return ( ); } function StoreGlyph() { return ( ); } function DiamondGlyph() { return ( ); } function LightningGlyph() { return ( ); } function AssetGlyph() { return ( ); } function WorkflowGlyph() { return ( ); } function GridTilesGlyph() { return ( ); } function MapPinGlyph() { return ( ); } function MagnetGlyph() { return ( ); } function PresetWorkflowControls({ anchor, functionConfig, mode, modelOptions, nodes, onComposerSubmit, onImageToolAction, onPresetSubmit, selectedNode, }: { anchor: PresetControlAnchor; functionConfig: VideoCanvasFunctionConfig; mode: Exclude; modelOptions: CanvasModelOption[]; nodes: CloneNode[]; onComposerSubmit: (payload: ComposerSubmitPayload) => void; onImageToolAction: (action: ImageToolbarAction, option?: string) => void; onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void; selectedNode: CloneNode; }) { if (mode === CHARACTER_THREE_VIEW_FUNCTION_KEY) { const definition = getConfiguredFunction(functionConfig, CHARACTER_THREE_VIEW_FUNCTION_KEY); return (
); } if (mode === "story-script") { const definition = getConfiguredFunction(functionConfig, "story-script"); return (
); } if (mode === "first-frame") { const definition = getConfiguredFunction(functionConfig, "first-frame-video"); return (
); } const definition = getConfiguredFunction(functionConfig, "audio-to-video"); return (
); } function TextTargetModuleComposer({ anchor, defaultModel = "auto", modelOptions = [], node, onSubmit, }: { anchor: PresetControlAnchor; defaultModel?: string; modelOptions?: CanvasModelOption[]; node: CloneNode; onSubmit: (prompt: string, model: string) => void; }) { const contentValues = node.draftValues?.content; const initialPrompt = Array.isArray(contentValues) && typeof contentValues[0] === "string" ? contentValues[0] : ""; const initialModel = normalizeModelRef( ((node.draftValues?.params as Record | undefined)?.model as string | undefined) || defaultModel, ) || "auto"; const [menu, setMenu] = useState(null); const [selectedModel, setSelectedModel] = useState(initialModel); const [prompt, setPrompt] = useState(initialPrompt); useEffect(() => { setPrompt(initialPrompt); }, [initialPrompt, node.id]); useEffect(() => { setSelectedModel(initialModel); }, [initialModel, node.id]); return (
{menu === "model" ? ( { setSelectedModel(option); setMenu(null); }} /> ) : null}