"use client"; import { Bell, Check, ChevronDown, Clock3, Copy, Download, FileText, GitBranch, Headphones, HelpCircle, Image as ImageIcon, Layers, Loader2, Music2, Plus, Play, RotateCcw, 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 { getGenerationCreditCostFromImageGenConfig, getImageCreditCostFromConfig, } from "@/lib/ai/image-model-pricing"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { defaultPointsSystemConfig, getImageToolCreditCost, normalizePointsSystemConfig, type ImageToolCreditFeature, type PointsSystemConfig, } from "@/lib/points/config"; import { cn, fetcher } from "@/lib/utils"; import { canvasService, imageGenerationService, videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; import { getVideoCanvasFunctionBillingCopy, type VideoCanvasFunctionConfig, type VideoCanvasFunctionDefinition, defaultVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; 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 ActivePresetControlMode = | "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | "story-beat-grid-4" | "multi-view-nine-grid" | "continuous-storyboard-grid-25" | "cinematic-lighting-correction" | "frame-prediction-plus-3s" | "frame-prediction-minus-5s" | null; type SceneToolbarMenu = "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | null; type ComposerMenu = | "style" | "mark" | "focus" | "model" | "ratio" | "resolution" | "duration" | "camera" | "panorama" | "translate" | "settings" | "count" | "reference" | "slash" | null; type MemberStoreTab = "vip" | "history" | "credits"; type VideoToolbarMode = "clip" | "analysis" | null; 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 VideoComposerTabKey = | "text-to-video" | "reference-images" | "first-frame" | "first-last-frame" | "image-reference"; type VideoTargetComposerSubmitPayload = { mode: VideoGenerationMode; prompt: string; settings: ComposerSettings & { duration: string; }; tab: VideoComposerTabKey; }; type CanvasModelOption = { id: string; modelId?: string; displayName: string; providerName?: string; providerType?: string; supportsVision?: boolean; supportsImageGen?: boolean; isMultimodal?: boolean; isDefault?: boolean; isSubModel?: boolean; capabilities?: unknown; imageGenConfig?: { creditCostPerImage?: number | null; creditDiscountRate?: number | null; creditCostPerVideo?: number | null; videoDurationOptions?: number[] | null; creditCostPerVideoByDuration?: Record | null; creditCostPerVideoByResolution?: Record | null; } | null; }; 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" | "localEdit" | "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; }; const DEFAULT_SITE_NAME = "AI Chatbot"; function resolveSiteName(siteName?: string | null) { return String(siteName || DEFAULT_SITE_NAME).trim() || DEFAULT_SITE_NAME; } function getSiteShowcaseName(siteName?: string | null) { return `${resolveSiteName(siteName)} 创作广场`; } type MembershipSummaryResponse = { isHighVip?: boolean; currentPlanId?: string; currentPlan?: { id?: string; name?: string; monthlyPrice?: number; annualPrice?: number; monthlyCredits?: number; dailyFreeCredits?: number; features?: string[]; badge?: string; highlighted?: boolean; }; summary?: { currentCredits?: number | null; dailyFreeCredits?: number | null; usedThisMonth?: number | null; monthlyLimit?: number | null; remainingThisMonth?: number | null; totalImages?: number | null; }; pointsSystemConfig?: PointsSystemConfig | null; }; type MembershipPlan = { id: string; name: string; monthlyPrice: number; annualPrice: number; monthlyCredits: number; dailyFreeCredits: number; features: string[]; badge?: string; highlighted?: boolean; }; type PointsPackage = { id: string; name: string; points: number; price: number; currency: string; isEnabled: boolean; }; type AccountCenterResponse = { profile?: { name?: string; email?: string; currentPlanName?: string; currentPlanId?: string; availablePoints?: number; monthlyCredits?: number; dailyFreeCredits?: number; }; usageRecords?: Array<{ id: string; detail: string; status: "consumed" | "earned"; amount: number; createdAt: string; }>; billingRecords?: Array<{ id: string; date: string; category: string; amount: number; status: "completed" | "pending" | "failed"; invoice: string; }>; }; type VideoCanvasBillingSettings = { membershipConfig?: { enabled?: boolean; currency?: string; annualDiscountLabel?: string; plans?: MembershipPlan[]; }; pointsPackages?: PointsPackage[]; dulupayConfig?: { enabled?: boolean; defaultType?: string; }; }; type GridCellVisual = { fit?: "cover" | "contain"; objectPosition?: string; scale?: number; translateX?: number; translateY?: number; }; type GridCropRect = { height: number; width: number; x: number; y: number; }; type LocalEditRegion = { color: string; createdAt: number; id: string; index: number; prompt: string; rect: GridCropRect; }; type GridSplitSpec = { columns: number; detected: boolean; rects: GridCropRect[]; rows: number; }; type GridSplitState = { columns: number; detectedDivider: boolean; rects: GridCropRect[]; rows: number; selectedIndices: number[]; sourceMode: string; }; type AngleEditorSettings = { horizontalAngle: number; promptEnabled: boolean; verticalAngle: number; zoom: number; }; type VideoClipRange = { start: number; end: number; }; type VideoAnalysisRow = { id: string; sceneDescription: string; backgroundMusic: string; voiceAndSfx: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; type VideoAnalysisResponsePayload = { analysisRows?: Array<{ backgroundMusic?: string; imagePrompt?: string; keyframeUrl?: string; motionPrompt?: string; sceneDescription?: string; voiceAndSfx?: string; }>; framePreviews?: string[]; highlights?: string[]; raw?: string; recommendedNextSteps?: string[]; shotIdeas?: Array<{ title: string; description: string }>; summary?: string; }; type VideoAnalysisState = { status: "idle" | "loading" | "ready" | "error"; summary: string; rows: VideoAnalysisRow[]; error?: string; }; const capturedTools: CapturedTool[] = []; const capturedCounts: { byType?: Record } = {}; const capturedWorkflows: 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://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"], }, { title: "角色三视图", subtitle: "角色资产沉淀", coverUrl: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"], }, { title: "首帧图生视频", subtitle: "参考图到成片", coverUrl: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", steps: ["上传图片", "首帧图", "可灵 3.0", "预览"], }, { title: "音频生视频", subtitle: "音频驱动画面", coverUrl: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.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 LOCAL_EDIT_MAX_REGIONS = 8; const LOCAL_EDIT_COLORS = [ "#ff3b30", "#1677ff", "#00c853", "#ff9500", "#af52de", "#00bcd4", "#ff2d55", "#8d6e63", ]; const VIDEO_CLIP_FRAME_COUNT = 9; 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://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg"; const CHARACTER_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png"; const CHARACTER_THREE_VIEW_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png"; const FIRST_FRAME_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/first-frame-image.png"; const AUDIO_SOURCE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-source.mp3"; const AUDIO_REFERENCE_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-reference-image.png"; const PUBLISH_MODAL_BG_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png"; 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://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/hd-image-source.png"; const VIDEO_FIRST_FRAME_SOURCE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-frame-source.png"; const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。"; const VIDEO_FIRST_LAST_FIRST_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-last-first.png"; const VIDEO_FIRST_LAST_LAST_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-last-last.png"; const VIDEO_FIRST_LAST_PROMPT = "手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然"; const SCRIPT_VIDEO_REFERENCE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-video-reference.mp4"; const SCRIPT_CHARACTER_ONE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-character-one.png"; const SCRIPT_CHARACTER_TWO_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-character-two.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 || "工作流预设", 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: "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 getNodeReferenceMediaUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl"); } function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node); } function isVideoMediaNode(node: CloneNode | null | undefined): node is CloneNode { if (!node || node.kind !== "media") return false; const mediaType = getDraftString(node, "mediaType") || node.inputType || ""; return mediaType === "video"; } function getVideoNodePreviewUrl(node: CloneNode | null | undefined) { return getDraftString(node, "poster") || getNodeSourceMediaUrl(node) || getNodeMediaUrl(node); } function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) { const params = getNodeParams(node); const paramsSettings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const numericValue = Number( params.duration || paramsSettings.duration || getDraftString(node, "duration") || (node?.draftValues?.videoMeta && typeof node.draftValues.videoMeta === "object" ? (node.draftValues.videoMeta as Record).duration : 0) ); if (Number.isFinite(numericValue) && numericValue > 0) return numericValue; const outputLabel = getDraftString(node, "outputSizeLabel"); const durationMatch = outputLabel.match(/(\d+(?:\.\d+)?)\s*s/i); if (durationMatch) { const parsed = Number(durationMatch[1] || "0"); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return 5; } function formatClipDurationLabel(seconds: number) { const normalized = Math.max(0.1, seconds); return `${normalized.toFixed(2)} s`; } function normalizeVideoClipRange(value: unknown): VideoClipRange { if (!value || typeof value !== "object") return { start: 0.18, end: 0.52 }; const candidate = value as Partial; const start = Number(candidate.start); const end = Number(candidate.end); if (!Number.isFinite(start) || !Number.isFinite(end)) { return { start: 0.18, end: 0.52 }; } return { start: Math.max(0, Math.min(start, 0.94)), end: Math.max(Math.min(start + 0.04, 1), Math.min(end, 1)), }; } function buildVideoAnalysisRows(node: CloneNode, responseData?: VideoAnalysisResponsePayload): VideoAnalysisRow[] { const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || ""; const modelRows = Array.isArray(responseData?.analysisRows) ? responseData.analysisRows : []; const framePreviews = Array.isArray(responseData?.framePreviews) ? responseData.framePreviews.map((item) => String(item || "").trim()).filter(Boolean) : []; if (modelRows.length > 0) { return modelRows.slice(0, 8).map((row, index) => ({ id: `${node.id}-analysis-${index}`, sceneDescription: String(row.sceneDescription || `镜头 ${index + 1}`).trim(), backgroundMusic: String(row.backgroundMusic || "无").trim(), voiceAndSfx: String(row.voiceAndSfx || "无").trim(), imagePrompt: String(row.imagePrompt || row.sceneDescription || `${node.title} 关键帧`).trim(), motionPrompt: String(row.motionPrompt || "保持镜头运动自然、连贯。").trim(), keyframeUrl: String(row.keyframeUrl || "").trim() || framePreviews[index % Math.max(framePreviews.length, 1)] || previewUrl, })); } const highlights = responseData?.highlights || []; const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || []; const summary = responseData?.summary?.trim() || ""; const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`; const rows = shotIdeas.length > 0 ? shotIdeas.map((idea, index) => ({ id: `${node.id}-analysis-${index}`, sceneDescription: idea.title || `镜头 ${index + 1}`, backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入", voiceAndSfx: highlights[index + 1] || "无", imagePrompt: idea.description || fallbackPrompt, motionPrompt: responseData?.recommendedNextSteps?.[index] || "保持镜头运动自然平稳,围绕主体动作推进。", keyframeUrl: framePreviews[index % Math.max(framePreviews.length, 1)] || previewUrl, })) : [ { id: `${node.id}-analysis-0`, sceneDescription: `${node.title} 开场镜头`, backgroundMusic: highlights[0] || "轻氛围背景音乐", voiceAndSfx: highlights[1] || "无", imagePrompt: fallbackPrompt, motionPrompt: "中景起势,镜头跟随主体动作缓慢推进。", keyframeUrl: framePreviews[0] || previewUrl, }, { id: `${node.id}-analysis-1`, sceneDescription: `${node.title} 情绪推进`, backgroundMusic: highlights[2] || highlights[0] || "音乐情绪逐渐抬升", voiceAndSfx: highlights[3] || "环境声增强", imagePrompt: fallbackPrompt, motionPrompt: "切换近景,强调表情和动作细节。", keyframeUrl: framePreviews[1] || framePreviews[0] || previewUrl, }, ]; return rows; } function getPersistedVideoAnalysisState(node: CloneNode | null | undefined): VideoAnalysisState | null { if (!node) return null; const raw = node?.draftValues?.videoAnalysis && typeof node.draftValues.videoAnalysis === "object" && !Array.isArray(node.draftValues.videoAnalysis) ? (node.draftValues.videoAnalysis as Record) : null; if (!raw) return null; const summary = String(raw.summary || "").trim() || "已保存视频解析结果"; const responseData: VideoAnalysisResponsePayload = { analysisRows: Array.isArray(raw.rows) ? (raw.rows as VideoAnalysisResponsePayload["analysisRows"]) : [], framePreviews: Array.isArray(raw.framePreviews) ? raw.framePreviews.map((item) => String(item || "").trim()) : [], summary, }; const rows = buildVideoAnalysisRows(node, responseData); if (rows.length === 0) return null; return { rows, status: "ready", summary, }; } function getGridSplitCellLabel(index: number, split: Pick) { const row = Math.floor(index / split.columns) + 1; const column = (index % split.columns) + 1; return `${row}-${column}`; } 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 isOfficialImagePresetFunctionKey(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 normalizeVideoGenerationMode(value: unknown): VideoGenerationMode | null { const mode = String(value || "").trim(); if ( mode === "text-to-video" || mode === "first-frame" || mode === "first-last-frame" || mode === "reference-images" ) { return mode; } return null; } function normalizeVideoRatioValue(value: unknown): VideoRatio { const ratio = String(value || "").trim(); if ( ratio === "16:9" || ratio === "4:3" || ratio === "1:1" || ratio === "3:4" || ratio === "9:16" || ratio === "21:9" || ratio === "adaptive" ) { return ratio; } return "16:9"; } function normalizeVideoResolutionValue(value: unknown): VideoResolution { const normalized = String(value || "") .trim() .toLowerCase() .replace(/\s+/g, ""); if (normalized === "480p") return "480p"; if (normalized === "1080p" || normalized === "1k") return "1080p"; return "720p"; } function formatVideoResolutionLabel(value: unknown) { const resolution = normalizeVideoResolutionValue(value); return resolution === "480p" ? "480P" : resolution === "1080p" ? "1080P" : "720P"; } function normalizeVideoDurationValue(value: unknown) { const matched = String(value || "").match(/(\d+)/); const duration = matched ? Number.parseInt(matched[1], 10) : Number(value); if (Number.isFinite(duration) && duration > 0) return duration; return 5; } function formatVideoDurationLabel(value: unknown) { return `${normalizeVideoDurationValue(value)}s`; } function inferVideoComposerDefaultTab(imageCount: number): VideoComposerTabKey { if (imageCount >= 2) return "reference-images"; if (imageCount >= 1) return "reference-images"; return "text-to-video"; } function resolveVideoComposerMode(tab: VideoComposerTabKey): VideoGenerationMode { if (tab === "first-last-frame") return "first-last-frame"; if (tab === "first-frame") return "first-frame"; if (tab === "reference-images" || tab === "image-reference") return "reference-images"; return "text-to-video"; } function getVideoFunctionKeyForMode(mode: VideoGenerationMode) { if (mode === "text-to-video") return "text-to-video" as const; if (mode === "first-last-frame") return "first-last-frame-video" as const; return "first-frame-video" as const; } function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode) { if (mode === "text-to-video") return []; if (mode === "first-frame") return images.slice(0, 1); if (mode === "first-last-frame") return images.slice(0, 2); return images.slice(0, 4); } 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; } type VideoCanvasBillingDisplay = { label: string; message: string; }; function getCanvasModelOptionByRef( modelRef: string | undefined, models: CanvasModelOption[], ) { const normalized = normalizeModelRef(modelRef); if (!normalized) return null; return ( models.find( (model) => model.id === normalized || model.modelId === normalized || getCanvasModelRef(model) === normalized, ) || null ); } function getVideoCanvasFallbackBillingDisplay( definition?: Pick | null, ): VideoCanvasBillingDisplay { const copy = definition ? getVideoCanvasFunctionBillingCopy(definition) : { shortLabel: "按站点", description: "沿用站点统一模型与功能积分规则,不在视频画布单独改价。", }; return { label: copy.shortLabel, message: copy.description, }; } function resolveVideoCanvasBillingDisplay(params: { membershipSummary?: MembershipSummaryResponse; modelOptions?: CanvasModelOption[]; modelRef?: string; modelKind?: CanvasModelKind; imageCount?: number; duration?: number | null; resolution?: string | null; billingFeature?: ImageToolCreditFeature | null; functionDefinition?: Pick | null; }): VideoCanvasBillingDisplay { const fallback = getVideoCanvasFallbackBillingDisplay(params.functionDefinition); const pointsConfig = normalizePointsSystemConfig( params.membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig, ); if (params.membershipSummary?.isHighVip) { return { label: "VIP", message: "当前高阶会员按站点规则免扣积分。", }; } if (pointsConfig.enabled !== true) { return { label: "免费", message: "当前站点未启用积分扣费。", }; } if (params.billingFeature) { const featureCost = getImageToolCreditCost(pointsConfig, params.billingFeature); if (featureCost > 0) { return { label: String(featureCost), message: `当前功能按全站积分设置消耗 ${featureCost} 积分。`, }; } return fallback; } const matchedModel = getCanvasModelOptionByRef( params.modelRef, params.modelOptions || [], ); if (!matchedModel?.imageGenConfig) { return fallback; } if (params.modelKind === "video") { const videoCost = getGenerationCreditCostFromImageGenConfig( matchedModel.imageGenConfig, "video", { duration: params.duration, resolution: params.resolution, }, ); if (videoCost > 0) { return { label: String(videoCost), message: `当前视频模型按站点配置预计消耗 ${videoCost} 积分。`, }; } return fallback; } if (params.modelKind === "image") { const unitCost = getImageCreditCostFromConfig(matchedModel.imageGenConfig); if (unitCost > 0) { const safeImageCount = Math.max( 1, Math.round(Number(params.imageCount || 1)), ); const totalCost = unitCost * safeImageCount; return { label: String(unitCost), message: safeImageCount > 1 ? `当前图片模型按站点配置 ${unitCost} 积分/张,本次共 ${totalCost} 积分。` : `当前图片模型按站点配置消耗 ${unitCost} 积分/张。`, }; } } return fallback; } 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 wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } 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 normalizeGridRect(value: unknown, fallback: GridCropRect): GridCropRect { if (!value || typeof value !== "object") return fallback; const record = value as Record; return { height: Math.min(1, Math.max(0.02, Number(record.height ?? fallback.height))), width: Math.min(1, Math.max(0.02, Number(record.width ?? fallback.width))), x: Math.min(0.98, Math.max(0, Number(record.x ?? fallback.x))), y: Math.min(0.98, Math.max(0, Number(record.y ?? fallback.y))), }; } function normalizeGridSplitState(value: unknown): GridSplitState | null { if (!value || typeof value !== "object") return null; const record = value as Record; const rows = Math.max(1, Math.min(5, Math.round(Number(record.rows || 1)))); const columns = Math.max(1, Math.min(5, Math.round(Number(record.columns || 1)))); const cellCount = rows * columns; const fallbackRects = createEqualGridRects(rows, columns); const rawRects = Array.isArray(record.rects) ? record.rects.slice(0, cellCount) : []; const rects = Array.from({ length: cellCount }).map((_, index) => normalizeGridRect(rawRects[index], fallbackRects[index] || fallbackRects[0]) ); const selectedIndices = Array.from( new Set( (Array.isArray(record.selectedIndices) ? record.selectedIndices : []) .map((item) => Math.round(Number(item))) .filter((index) => Number.isFinite(index) && index >= 0 && index < cellCount) ) ).sort((left, right) => left - right); return { columns, detectedDivider: Boolean(record.detectedDivider), rects, rows, selectedIndices, sourceMode: String(record.sourceMode || "uniform-grid"), }; } function getNodeGridSplitState(node: CloneNode | null | undefined) { return normalizeGridSplitState(node?.draftValues?.gridSplit); } function normalizeLocalEditRegion(value: unknown, index: number): LocalEditRegion | null { if (!value || typeof value !== "object") return null; const record = value as Record; const rawRect = record.rect && typeof record.rect === "object" ? (record.rect as Record) : null; if (!rawRect) return null; const width = Math.min(1, Math.max(0.001, Number(rawRect.width || 0))); const height = Math.min(1, Math.max(0.001, Number(rawRect.height || 0))); const x = Math.min(1 - width, Math.max(0, Number(rawRect.x || 0))); const y = Math.min(1 - height, Math.max(0, Number(rawRect.y || 0))); return { color: typeof record.color === "string" && /^#[0-9a-f]{6}$/i.test(record.color) ? record.color : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], createdAt: Number(record.createdAt || Date.now()), id: String(record.id || makeId("local-edit-region")), index: index + 1, prompt: typeof record.prompt === "string" ? record.prompt : "", rect: { height, width, x, y }, }; } function getNodeLocalEditRegions(node: CloneNode | null | undefined) { const regions = Array.isArray(node?.draftValues?.localEditRegions) ? node?.draftValues?.localEditRegions : []; return regions .slice(0, LOCAL_EDIT_MAX_REGIONS) .map((region, index) => normalizeLocalEditRegion(region, index)) .filter((region): region is LocalEditRegion => Boolean(region)) .map((region, index) => ({ ...region, index: index + 1 })); } function withReindexedLocalEditRegions(regions: LocalEditRegion[]) { return regions .slice(0, LOCAL_EDIT_MAX_REGIONS) .map((region, index) => ({ ...region, index: index + 1, color: region.color || LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], })); } function clearGridSplitDraftValues(draftValues: Record | undefined) { const nextDraftValues = { ...(draftValues || {}) }; delete nextDraftValues.gridSplit; return nextDraftValues; } 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; const darkScore = luminance < 96 ? ((96 - luminance) / 96) * alpha : 0; const lightScore = luminance > 220 ? ((luminance - 220) / 35) * alpha : 0; score += Math.max(darkScore, lightScore); 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; } } async function loadCanvasImage(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); if (!url.startsWith("blob:") && !url.startsWith("data:")) { image.crossOrigin = "anonymous"; image.referrerPolicy = "no-referrer"; } image.onload = () => resolve(image); image.onerror = () => reject(new Error("image-load-failed")); image.src = url; }); } async function cropRectToBlobUrlSource(url: string, rect: GridCropRect): Promise { if (typeof window === "undefined" || !url) return null; const image = await loadCanvasImage(url); const naturalWidth = image.naturalWidth || image.width; const naturalHeight = image.naturalHeight || image.height; if (!naturalWidth || !naturalHeight) return null; const cropX = Math.max(0, Math.floor(rect.x * naturalWidth)); const cropY = Math.max(0, Math.floor(rect.y * naturalHeight)); const cropWidth = Math.max(1, Math.round(rect.width * naturalWidth)); const cropHeight = Math.max(1, Math.round(rect.height * naturalHeight)); const canvas = document.createElement("canvas"); canvas.width = cropWidth; canvas.height = cropHeight; const context = canvas.getContext("2d"); if (!context) return null; context.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); return await new Promise((resolve) => { canvas.toBlob((blob) => resolve(blob), "image/png", 1); }); } async function createGridSplitReferenceAsset({ index, nodeId, rect, sourceUrl, }: { index: number; nodeId: string; rect: GridCropRect; sourceUrl: string; }) { if (/^https?:\/\//i.test(sourceUrl)) { const response = await imageGenerationService.boxCutout({ cropRect: rect, imageUrl: sourceUrl, paddingRatio: 0, sourceNodeId: nodeId, }); if (response.success && response.data?.url) return response.data.url; } const blob = await cropRectToBlobUrlSource(sourceUrl, rect); if (!blob) return ""; const file = new File([blob], `video-canvas-grid-${nodeId}-${index + 1}.png`, { type: blob.type || "image/png", }); const upload = await imageGenerationService.uploadImage(file); return upload.success ? upload.data?.url || "" : ""; } async function ensureRemoteCanvasImageUrl(sourceUrl: string, filenamePrefix: string) { if (/^https?:\/\//i.test(sourceUrl)) return sourceUrl; if (!sourceUrl || typeof window === "undefined") return ""; const response = await fetch(sourceUrl); if (!response.ok) return ""; const blob = await response.blob(); const extension = blob.type.includes("jpeg") || blob.type.includes("jpg") ? "jpg" : blob.type.includes("webp") ? "webp" : "png"; const file = new File([blob], `${filenamePrefix}-${Date.now()}.${extension}`, { type: blob.type || `image/${extension}`, }); const upload = await imageGenerationService.uploadImage(file); return upload.success ? upload.data?.url || "" : ""; } async function resolveNodeReferenceImages(node: CloneNode | null | undefined, limit = 12): Promise { const fallbackUrl = getNodeReferenceMediaUrl(node); const split = getNodeGridSplitState(node); const sourceUrl = getNodeGridSplitSourceUrl(node); if (!split || split.selectedIndices.length === 0 || !sourceUrl) { return fallbackUrl ? [fallbackUrl] : []; } const selectedIndices = split.selectedIndices.slice(0, limit); const resolved = await Promise.all( selectedIndices.map(async (index) => { const rect = split.rects[index]; if (!rect) return ""; try { return await createGridSplitReferenceAsset({ index, nodeId: node?.id || "grid", rect, sourceUrl, }); } catch { return ""; } }) ); const urls = Array.from( new Set( resolved .map((item) => String(item || "").trim()) .filter(Boolean) ) ); return urls.length > 0 ? urls : fallbackUrl ? [fallbackUrl] : []; } 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 getPresetControlMode(node: CloneNode | null | undefined): ActivePresetControlMode { const starterMode = getPresetMode(node); if (starterMode) return starterMode; const functionKey = resolveOfficialImagePresetFunctionKey({ fallbackKey: getNodeFunctionKey(node), option: getNodeParams(node).option || node?.draftValues?.toolbarOption, title: node?.title, }); return functionKey && isOfficialImagePresetFunctionKey(functionKey) ? functionKey : null; } function isOfficialImageControlNode(node: CloneNode | null | undefined): node is CloneNode { return !!node && (node.kind === "media" || node.kind === "script") && !!getPresetControlMode(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?scope=branding", fetcher ); const { data: videoCanvasFunctionSettings } = useSWR<{ config?: VideoCanvasFunctionConfig }>( "/api/video-canvas/config", fetcher ); const { data: canvasModelsData } = useSWR<{ models?: CanvasModelOption[] }>( "/api/models", fetcher ); const { data: membershipSummary } = useSWR( "/api/user/membership-summary", fetcher ); const videoCanvasFunctionConfig = useMemo( () => normalizeVideoCanvasFunctionConfig(videoCanvasFunctionSettings?.config), [videoCanvasFunctionSettings?.config] ); const canvasModelOptions = useMemo( () => (canvasModelsData?.models || []).filter((model) => getCanvasModelRef(model)), [canvasModelsData?.models] ); const resolvedSiteName = useMemo( () => resolveSiteName(siteBrandSettings?.siteName), [siteBrandSettings?.siteName] ); 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 [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState(null); const [videoClipRanges, setVideoClipRanges] = useState>({}); const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, [nodes, selectedNodeId] ); const selectedVideoMediaNode = useMemo( () => (isVideoMediaNode(selectedNode) && Boolean(getNodeMediaUrl(selectedNode)) ? selectedNode : null), [selectedNode] ); 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(() => { setActiveVideoToolbarMode(null); }, [selectedVideoMediaNode?.id]); 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]); setSelectedNodeId(node.id); setCanvasCommand(null); setNodeContextMenu(null); setActivePanel(null); 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 anchorSourceUrl = getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL; if (action === "download") { if (!anchorSourceUrl) { toast.error("当前素材没有可下载地址"); return; } const link = document.createElement("a"); link.href = anchorSourceUrl; link.download = `${anchor.title || "未命名图片"}.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 === "localEdit") { const mediaUrl = getNodeReferenceMediaUrl(anchor); if (!mediaUrl) { toast.error("当前图片没有可编辑的图片地址"); return; } if (getNodeGridSplitState(anchor)) { toast.error("请先退出宫格切分,再使用局部编辑"); return; } const willEnableLocalEdit = !Boolean(anchor.draftValues?.localEditMode); setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: { ...(node.draftValues || {}), localEditMode: willEnableLocalEdit, localEditRegions: getNodeLocalEditRegions(node), }, } : node ) ); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(willEnableLocalEdit ? "已进入局部编辑模式" : "已退出局部编辑模式"); 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); if (action === "crop") { const { columns, rows, tileCount } = normalizeGridOption(option); const currentSplit = getNodeGridSplitState(anchor); if (currentSplit && currentSplit.columns === columns && currentSplit.rows === rows) { setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: clearGridSplitDraftValues(node.draftValues), } : node ) ); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success("已取消宫格切分"); return; } const rects = createEqualGridRects(rows, columns); setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: { ...(node.draftValues || {}), gridSplit: { columns, detectedDivider: false, rects, rows, selectedIndices: [], sourceMode: "uniform-grid", }, }, } : node ) ); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(`已在当前图上按 ${columns}x${rows} 进入 ${tileCount} 宫格选择模式`); return; } const resolvedReferenceImages = await resolveNodeReferenceImages(anchor); const sourceUrl = resolvedReferenceImages[0] || anchorSourceUrl; const extraReferenceImages = resolvedReferenceImages.slice(1); 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, referenceImages: [sourceUrl, ...extraReferenceImages], 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, referenceImages: extraReferenceImages, }); 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, referenceImages: [sourceUrl, ...extraReferenceImages], }, 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, referenceImages: [sourceUrl, ...extraReferenceImages], 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, referenceImages: extraReferenceImages, }); 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, referenceImages: [sourceUrl, ...extraReferenceImages], }, 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, referenceImages: [sourceUrl, ...extraReferenceImages], 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, referenceImages: extraReferenceImages, }); 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, referenceImages: [sourceUrl, ...extraReferenceImages], 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, referenceImages: [sourceUrl, ...extraReferenceImages], 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 dismissNodeGridSplit = useCallback((nodeId: string) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: clearGridSplitDraftValues(node.draftValues), } : node ) ); }, []); const clearNodeGridSplitSelection = useCallback((nodeId: string) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const split = getNodeGridSplitState(node); if (!split) return node; return { ...node, draftValues: { ...(node.draftValues || {}), gridSplit: { ...split, selectedIndices: [], }, }, }; }) ); }, []); const toggleNodeGridSplitSelection = useCallback( (nodeId: string, index: number, behavior: "replace" | "toggle" = "replace") => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const split = getNodeGridSplitState(node); if (!split || !split.rects[index]) return node; const alreadySelected = split.selectedIndices.includes(index); const nextSelected = behavior === "toggle" ? alreadySelected ? split.selectedIndices.filter((item) => item !== index) : [...split.selectedIndices, index].sort((left, right) => left - right) : alreadySelected && split.selectedIndices.length === 1 ? [] : [index]; return { ...node, draftValues: { ...(node.draftValues || {}), gridSplit: { ...split, selectedIndices: nextSelected, }, }, }; }) ); }, [] ); const setNodeLocalEditMode = useCallback((nodeId: string, enabled: boolean) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), localEditMode: enabled, }, } : node ) ); }, []); const updateNodeLocalEditRegions = useCallback((nodeId: string, regions: LocalEditRegion[]) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), localEditRegions: withReindexedLocalEditRegions(regions), }, } : node ) ); }, []); const clearNodeLocalEditRegions = useCallback((nodeId: string) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), localEditRegions: [], }, } : node ) ); }, []); const pollLocalEditTaskForNode = useCallback(async (nodeId: string, taskId: string) => { for (let attempt = 0; attempt < 180; attempt += 1) { await wait(attempt < 6 ? 1500 : 2500); const response = await imageGenerationService.getImageTask(taskId); if (!response.success || !response.data?.task) { if (attempt > 4) { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), generationError: response.error || "局部编辑任务查询失败", generationStatus: "failed", }, } : node ) ); toast.error(response.error || "局部编辑任务查询失败"); return; } continue; } const { task } = response.data; if (task.status === "succeeded") { const first = task.result?.images?.[0]; const generatedUrl = first?.originalUrl || first?.thumbnailUrl || ""; if (!generatedUrl) { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), generationError: "局部编辑已完成但未返回图片", generationStatus: "failed", }, } : node ) ); toast.error("局部编辑已完成但未返回图片"); return; } setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, subtitle: "生成结果", draftValues: { ...(node.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", localEditMode: false, localEditRegions: [], mediaUrl: generatedUrl, modelRef: task.result?.modelRef, params: { ...getNodeParams(node), model: task.result?.model, taskId, }, url: [generatedUrl], }, } : node ) ); setSelectedNodeId(nodeId); toast.success("局部编辑结果已生成"); return; } if (task.status === "failed") { const errorMessage = task.error || "局部编辑生成失败"; setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, subtitle: "生成失败", draftValues: { ...(node.draftValues || {}), generationError: errorMessage, generationStatus: "failed", }, } : node ) ); toast.error(errorMessage); return; } const progress = Math.min(96, 8 + attempt * 2); setNodes((current) => current.map((node) => node.id === nodeId && getDraftString(node, "generationStatus") === "pending" ? { ...node, draftValues: { ...(node.draftValues || {}), generationProgress: Math.max(getDraftNumber(node, "generationProgress", 2), progress), }, } : node ) ); } setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), generationError: "局部编辑任务等待超时,请稍后重试", generationStatus: "failed", }, } : node ) ); toast.error("局部编辑任务等待超时,请稍后重试"); }, []); const submitNodeLocalEdit = useCallback( async (nodeId: string, regions: LocalEditRegion[]) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const validRegions = withReindexedLocalEditRegions(regions).filter((region) => region.prompt.trim()); if (validRegions.length === 0) { toast.error("请至少框选一个区域并填写提示词"); return; } const rawSourceUrl = getNodeReferenceMediaUrl(anchor); if (!rawSourceUrl) { toast.error("当前图片没有可编辑的图片地址"); return; } toast.info("正在提交局部编辑任务..."); const sourceUrl = await ensureRemoteCanvasImageUrl(rawSourceUrl, `canvas-local-edit-${anchor.id}`).catch(() => ""); if (!sourceUrl) { toast.error("当前图片无法上传为可编辑素材"); return; } const response = await imageGenerationService.localEdit({ aspectRatio: "auto", imageSize: "2K", regions: validRegions.map((region) => ({ color: region.color, id: region.id, index: region.index, prompt: region.prompt.trim(), rect: region.rect, })), sourceImageUrl: sourceUrl, sourceNodeId: anchor.id, }); if (!response.success || !response.data?.taskId) { toast.error(response.error || "局部编辑提交失败"); return; } const sourceWidth = Number(response.data.sourceWidth || 0); const sourceHeight = Number(response.data.sourceHeight || 0); const sourceRatio = sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : 0; const resultWidth = anchor.width; const resultHeight = sourceRatio > 0 ? Math.max(120, Math.round(resultWidth / sourceRatio)) : anchor.height; const resultNode = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: resultHeight, mediaType: "image", mediaUrl: "", prompt: response.data.prompt, title: "局部编辑", width: resultWidth, x: Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP), y: anchor.y, }); const pendingNode: CloneNode = { ...resultNode, subtitle: "生成中", draftValues: { ...(resultNode.draftValues || {}), action: "IMAGE_RESOURCE", annotatedReferenceUrl: response.data.annotatedReferenceUrl, aspectRatio: response.data.aspectRatio, functionKey: "local-edit", generated: false, generationProgress: 6, generationStatus: "pending", localEditMode: false, localEditRegions: [], mediaType: "image", mediaUrl: "", nodeType: "image", params: { annotatedReferenceUrl: response.data.annotatedReferenceUrl, aspectRatio: response.data.aspectRatio, mode: "local-edit", prompt: response.data.prompt, regions: validRegions, sourceNodeId: anchor.id, sourceHeight: response.data.sourceHeight, sourceUrl, sourceWidth: response.data.sourceWidth, taskId: response.data.taskId, }, pendingLabel: "局部编辑中", prompt: response.data.prompt, sourceNodeId: anchor.id, sourceLocalEditRegions: validRegions, sourceNaturalHeight: response.data.sourceHeight, sourceNaturalWidth: response.data.sourceWidth, sourceUrl, toolbarOption: "局部编辑", url: [], }, }; setNodes((current) => current.map((node) => node.id === anchor.id ? { ...node, draftValues: { ...(node.draftValues || {}), localEditMode: false, localEditRegions: validRegions, }, } : node ).concat(pendingNode) ); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]); setSelectedNodeId(pendingNode.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success("已创建局部编辑生成任务"); void pollLocalEditTaskForNode(pendingNode.id, response.data.taskId); }, [nodes, pollLocalEditTaskForNode] ); const createGridSplitImageNodes = useCallback( async (nodeId: string) => { const anchor = nodes.find((node) => node.id === nodeId); const split = getNodeGridSplitState(anchor); const sourceUrl = getNodeGridSplitSourceUrl(anchor); if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) { toast.error("请先选择需要处理的宫格"); return; } const selectedIndices = split.selectedIndices.slice(); const selectedAssets = await Promise.all( selectedIndices.map(async (index) => { const rect = split.rects[index]; if (!rect) return null; const url = await createGridSplitReferenceAsset({ index, nodeId: anchor.id, rect, sourceUrl, }).catch(() => ""); if (!url) return null; return { index, rect, url }; }) ); const validAssets = selectedAssets.filter((item): item is NonNullable => Boolean(item)); if (validAssets.length === 0) { toast.error("所选宫格暂时无法切出独立图片"); return; } const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const columns = validAssets.length >= 4 ? 2 : 1; const columnGap = 52; const rowGap = 34; const tileWidth = 428; const tileHeight = 240; const totalHeight = Math.ceil(validAssets.length / columns) * tileHeight + Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap; const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2); const createdNodes = validAssets.map((asset, order) => { const column = order % columns; const row = Math.floor(order / columns); const title = `镜头 ${getGridSplitCellLabel(asset.index, split)}`; return createMediaResourceNode({ action: "IMAGE_RESOURCE", height: tileHeight, mediaType: "image", mediaUrl: asset.url, title, width: tileWidth, x: startX + column * (tileWidth + columnGap), y: startY + row * (tileHeight + rowGap), }); }).map((node, order) => { const asset = validAssets[order]; return { ...node, subtitle: "宫格镜头", draftValues: { ...(node.draftValues || {}), cropTile: { columns: split.columns, detectedDivider: split.detectedDivider, index: asset.index, rect: asset.rect, rows: split.rows, sourceCompositeNodeId: anchor.id, sourceMode: split.sourceMode, }, mediaUrl: asset.url, outputSizeLabel: undefined, params: { ...(node.draftValues?.params as Record | undefined), cropRect: asset.rect, sourceNodeId: anchor.id, sourceUrl: asset.url, }, sourceCompositeNodeId: anchor.id, sourceNodeId: anchor.id, sourceUrl: asset.url, url: [asset.url], }, } satisfies CloneNode; }); setNodes((current) => [...current, ...createdNodes]); setEdges((current) => [ ...current, ...createdNodes.map((node) => ({ id: makeId("edge"), source: anchor.id, target: node.id, })), ]); setSelectedNodeId(createdNodes[0]?.id || anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success(`已创建 ${createdNodes.length} 个宫格图片节点`); }, [nodes] ); const upscaleSelectedGridSplit = useCallback( async (nodeId: string, scaleFactor: number) => { const anchor = nodes.find((node) => node.id === nodeId); const split = getNodeGridSplitState(anchor); const sourceUrl = getNodeGridSplitSourceUrl(anchor); if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) { toast.error("请先选择需要高清放大的宫格"); return; } const definition = getConfiguredFunction(videoCanvasFunctionConfig, "hd-upscale"); const selectedIndices = split.selectedIndices.slice(); const selectedAssets = await Promise.all( selectedIndices.map(async (index) => { const rect = split.rects[index]; if (!rect) return null; const url = await createGridSplitReferenceAsset({ index, nodeId: anchor.id, rect, sourceUrl, }).catch(() => ""); if (!url) return null; return { index, rect, url }; }) ); const validAssets = selectedAssets.filter((item): item is NonNullable => Boolean(item)); if (validAssets.length === 0) { toast.error("所选宫格暂时无法执行高清放大"); return; } const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const columns = validAssets.length >= 4 ? 2 : 1; const columnGap = 52; const rowGap = 34; const tileWidth = 560; const tileHeight = 315; const totalHeight = Math.ceil(validAssets.length / columns) * tileHeight + Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap; const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2); const pendingNodes = validAssets.map((asset, order) => { const column = order % columns; const row = Math.floor(order / columns); const cellLabel = getGridSplitCellLabel(asset.index, split); const pending = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: tileHeight, mediaType: "image", mediaUrl: asset.url, prompt: definition.prompt, title: `宫格高清 ${cellLabel}`, width: tileWidth, x: startX + column * (tileWidth + columnGap), y: startY + row * (tileHeight + rowGap), }); return { ...pending, subtitle: "生成中", draftValues: { ...(pending.draftValues || {}), action: "IMAGE_RESOURCE", functionKey: definition.key, generated: false, generationProgress: 2, generationStatus: "pending", mediaType: "image", mediaUrl: asset.url, nodeType: "image", outputSizeLabel: `${scaleFactor}x 高清`, params: { functionKey: definition.key, mode: "grid-split-upscale", prompt: definition.prompt, scaleFactor, sourceNodeId: anchor.id, sourceTileIndex: asset.index, sourceUrl: asset.url, }, pendingLabel: "生成中", prompt: definition.prompt, sourceCompositeNodeId: anchor.id, sourceNodeId: anchor.id, sourceUrl: asset.url, toolbarOption: `x${scaleFactor}`, url: [asset.url], }, } satisfies CloneNode; }); setNodes((current) => [...current, ...pendingNodes]); setEdges((current) => [ ...current, ...pendingNodes.map((node) => ({ id: makeId("edge"), source: anchor.id, target: node.id, })), ]); setSelectedNodeId(anchor.id); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); for (const [order, asset] of validAssets.entries()) { const pendingNode = pendingNodes[order]; const response = await imageGenerationService.enhanceImage(asset.url, { scaleFactor }); const generatedUrl = response.success ? response.data?.url || "" : ""; if (generatedUrl) { setNodes((current) => current.map((node) => node.id === pendingNode.id ? { ...node, subtitle: "生成结果", draftValues: { ...clearGridSplitDraftValues(node.draftValues), functionKey: definition.key, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "image", mediaUrl: generatedUrl, nodeType: "image", outputSizeLabel: `${scaleFactor}x 高清`, params: { ...getNodeParams(node), functionKey: definition.key, prompt: definition.prompt, scaleFactor, sourceNodeId: anchor.id, sourceTileIndex: asset.index, sourceUrl: asset.url, }, sourceCompositeNodeId: anchor.id, sourceNodeId: anchor.id, sourceUrl: asset.url, url: [generatedUrl], }, } : node ) ); } else { setNodes((current) => current.map((node) => node.id === pendingNode.id ? { ...node, draftValues: { ...clearGridSplitDraftValues(node.draftValues), generationStatus: "failed", generationError: response.error || "宫格高清放大失败", }, } : node ) ); } } toast.success(`已提交 ${validAssets.length} 张宫格图的 ${scaleFactor}x 高清放大`); }, [nodes, videoCanvasFunctionConfig] ); const submitSelectedImagePrompt = useCallback( async (nodeId: string, payload: ComposerSubmitPayload) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; const anchorSplit = getNodeGridSplitState(anchor); const anchorReferenceImages = await resolveNodeReferenceImages(anchor); const referencedNodes = payload.references .filter((referenceId) => referenceId !== anchor.id) .map((referenceId) => nodes.find((node) => node.id === referenceId)) .filter((node): node is CloneNode => Boolean(node)); const referencedImageLists = await Promise.all(referencedNodes.map((node) => resolveNodeReferenceImages(node))); const mergedReferenceImages = Array.from( new Set( [...anchorReferenceImages, ...referencedImageLists.flat()] .map((item) => String(item || "").trim()) .filter(Boolean) ) ).slice(0, 12); const sourceUrl = mergedReferenceImages[0] || getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL; const extraReferenceImages = mergedReferenceImages.slice(1); 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, referenceImages: extraReferenceImages, }); 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, referenceImages: mergedReferenceImages, selectedGridCellCount: anchorSplit?.selectedIndices.length || 0, 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 submitVideoTargetComposer = useCallback( async ( nodeId: string, payload?: VideoTargetComposerSubmitPayload, ) => { 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 imageReferenceLists = await Promise.all( incomingNodes .filter((item) => item.inputType === "image") .map((item) => resolveNodeReferenceImages(item)) ); const imageUrls = Array.from( new Set( imageReferenceLists .flat() .map((item) => String(item || "").trim()) .filter(Boolean) ) ).slice(0, 12); const params = (node.draftValues?.params || {}) as Record; const paramsSettings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const requestedMode = payload?.mode || normalizeVideoGenerationMode(params.mode || paramsSettings.mode) || (imageUrls.length >= 2 ? "first-last-frame" : imageUrls.length >= 1 ? "first-frame" : "text-to-video"); const definition = getConfiguredFunction( videoCanvasFunctionConfig, getVideoFunctionKeyForMode(requestedMode), ); const requestedSettings = payload?.settings; const preferredModelOverride = requestedSettings?.model || String(params.model || paramsSettings.model || ""); const preferredModelRef = getFunctionModel(videoCanvasFunctionConfig, definition, preferredModelOverride); const ratio = normalizeVideoRatioValue(requestedSettings?.ratio || params.ratio || paramsSettings.ratio); const resolution = normalizeVideoResolutionValue( requestedSettings?.resolution || params.resolution || paramsSettings.resolution, ); const duration = normalizeVideoDurationValue( requestedSettings?.duration || params.duration || paramsSettings.duration, ); const referenceImages = selectVideoReferenceImages(imageUrls, requestedMode); if (requestedMode === "first-frame" && referenceImages.length < 1) { toast.error("图生视频至少需要 1 张参考图片"); return; } if (requestedMode === "first-last-frame" && referenceImages.length < 2) { toast.error("首尾帧模式至少需要 2 张参考图片"); return; } if (requestedMode === "reference-images" && referenceImages.length < 1) { toast.error("全能参考至少需要 1 张参考图片"); return; } const rawPrompt = String( payload?.prompt ?? params.prompt ?? node.draftValues?.prompt ?? "", ).trim(); const finalPrompt = composeFunctionPrompt(definition, rawPrompt || node.title); const previewUrl = referenceImages[0] || getNodeReferenceMediaUrl(incomingNodes.find((item) => item.inputType === "image")) || getNodeReferenceMediaUrl(node) || ""; const savedSettings: ComposerSettings & { duration: string } = { camera: requestedSettings?.camera || String(paramsSettings.camera || "摄像机"), count: requestedSettings?.count || String(paramsSettings.count || "1个"), model: normalizeModelRef(preferredModelOverride) || "auto", panorama: requestedSettings?.panorama || String(paramsSettings.panorama || "全能参考"), ratio, resolution: formatVideoResolutionLabel(resolution), duration: formatVideoDurationLabel(duration), }; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成中", draftValues: { ...clearGridSplitDraftValues(item.draftValues), action: "VIDEO_GENERATE", generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", poster: previewUrl, params: { ...getNodeParams(item), duration, mode: requestedMode, model: normalizeModelRef(preferredModelOverride) || undefined, prompt: rawPrompt || finalPrompt, ratio, referenceImages, resolution, settings: savedSettings, }, prompt: rawPrompt || finalPrompt, sourceUrl: previewUrl, url: [], }, } : item ) ); try { const response = await videoGenerationService.generateVideo({ duration, images: referenceImages, mode: requestedMode, preferredModelRef, prompt: finalPrompt, ratio, resolution, }); if (!response.success || !response.data?.url) { throw new Error(response.error || "视频生成失败"); } setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成结果", draftValues: { ...clearGridSplitDraftValues(item.draftValues), action: "VIDEO_GENERATE", generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: response.data?.url, nodeType: "video", outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`, params: { ...getNodeParams(item), duration, mode: requestedMode, model: normalizeModelRef(preferredModelOverride) || undefined, prompt: rawPrompt || finalPrompt, ratio, referenceImages, resolution, settings: savedSettings, }, poster: previewUrl, prompt: rawPrompt || finalPrompt, sourceUrl: previewUrl, url: [response.data?.url], videoMeta: response.data?.metadata || null, }, } : item ) ); setSelectedNodeId(nodeId); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); toast.success("视频已开始生成并返回结果"); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", subtitle: "生成失败", draftValues: { ...clearGridSplitDraftValues(item.draftValues), generationStatus: "failed", generationError: error instanceof Error ? error.message : "视频生成失败", poster: previewUrl, }, } : item ) ); toast.error(error instanceof Error ? error.message : "视频生成失败"); } }, [edges, nodes, videoCanvasFunctionConfig] ); const updateVideoClipRange = useCallback((nodeId: string, nextRange: VideoClipRange) => { setVideoClipRanges((current) => ({ ...current, [nodeId]: { start: Math.max(0, Math.min(nextRange.start, 0.94)), end: Math.max(Math.min(nextRange.start + 0.04, 1), Math.min(nextRange.end, 1)), }, })); }, []); const applyVideoClipSelection = useCallback( (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const existingRange = normalizeVideoClipRange( videoClipRanges[nodeId] || nodes.find((item) => item.id === nodeId)?.draftValues?.clipRange ); const normalizedRange = existingRange.end > existingRange.start ? existingRange : { start: existingRange.start, end: Math.min(1, existingRange.start + 0.12) }; const durationSeconds = getVideoNodeDurationSeconds(node) * Math.max(0.04, normalizedRange.end - normalizedRange.start); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), clipDurationLabel: formatClipDurationLabel(durationSeconds), clipRange: normalizedRange, }, } : item ) ); setActiveVideoToolbarMode(null); toast.success("已记录当前剪辑范围,视频裁剪导出后端待接入。"); }, [nodes, videoClipRanges] ); const downloadVideoNode = useCallback((node: CloneNode) => { const mediaUrl = getNodeMediaUrl(node); if (!mediaUrl) { toast.error("当前视频没有可下载地址"); return; } const link = document.createElement("a"); link.href = mediaUrl; link.download = `${node.title || "未命名视频"}.mp4`; link.rel = "noreferrer"; document.body.appendChild(link); link.click(); link.remove(); toast.success("已开始下载当前视频"); }, []); const requestVideoAnalysis = useCallback( async (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const videoUrl = getNodeReferenceMediaUrl(node); const previewImageUrl = getVideoNodePreviewUrl(node); if (!videoUrl) { toast.error("当前视频暂时没有可解析的素材地址"); return; } if (videoAnalysisStates[nodeId]?.status === "loading" || videoAnalysisStates[nodeId]?.status === "ready") { return; } const definition = getConfiguredFunction(videoCanvasFunctionConfig, "video-analysis"); const prompt = composeFunctionPrompt( definition, `请解析当前视频,输出结构化视频故事表。重点补充镜头描述、背景音乐、人声/音效、图像生成提示词、视频运动提示词与关键帧建议。视频标题:${node.title}` ); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { error: undefined, rows: [], status: "loading", summary: "正在解析视频故事...", }, })); try { const response = await workflowAnalysisService.analyze({ imageUrls: previewImageUrl && previewImageUrl !== videoUrl ? [previewImageUrl] : undefined, mode: "video-analysis", nodeTitle: node.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition), prompt, videoUrls: [videoUrl], }); if (!response.success) { throw new Error(response.error || "视频解析失败"); } const rows = buildVideoAnalysisRows(node, response.data); const summary = response.data?.summary?.trim() || response.data?.highlights?.join(" · ") || "已整理出当前视频的镜头节奏与生成建议。"; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), videoAnalysis: { framePreviews: response.data?.framePreviews || [], rows, summary, }, }, } : item ) ); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { rows, status: "ready", summary, }, })); } catch (error) { const fallbackRows = buildVideoAnalysisRows(node); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), videoAnalysis: { rows: fallbackRows, summary: "解析失败,先展示本地占位结构,方便继续编排。", }, }, } : item ) ); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { error: error instanceof Error ? error.message : "视频解析失败", rows: fallbackRows, status: "error", summary: "解析失败,先展示本地占位结构,方便继续编排。", }, })); toast.error(error instanceof Error ? error.message : "视频解析失败"); } }, [nodes, videoAnalysisStates, videoCanvasFunctionConfig] ); const runPresetComposerSubmit = useCallback( async ( nodeId: string, mode: Exclude, draftPrompt: string, settings?: ComposerSettings, ) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; if (isOfficialImagePresetFunctionKey(mode)) 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) => getNodeReferenceMediaUrl(item)) .filter(Boolean); const videoUrls = incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeReferenceMediaUrl(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 imageReferenceLists = await Promise.all( incomingNodes .filter((item) => item.inputType === "image") .map((item) => resolveNodeReferenceImages(item)) ); const imageUrls = Array.from( new Set( imageReferenceLists .flat() .map((item) => String(item || "").trim()) .filter(Boolean) ) ).slice(0, 12); const videoUrls = incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeReferenceMediaUrl(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] || getNodeReferenceMediaUrl(node); const extraReferenceImages = imageUrls.slice(1); 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, referenceImages: extraReferenceImages, }); 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: { ...clearGridSplitDraftValues(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, referenceImages: sourceUrl ? [sourceUrl, ...extraReferenceImages] : extraReferenceImages, }, prompt, sourceUrl, url: [generatedUrl], }, } : item ) ); toast.success(`${node.title} 已生成图片结果`); return; } if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) { await submitVideoTargetComposer(nodeId); 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, submitVideoTargetComposer, 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 selectedGenericImageNode = selectedNode && !selectedPresetControlNode && selectedNode.kind === "media" && (getDraftString(selectedNode, "mediaType") || selectedNode.inputType) === "image" ? selectedNode : null; const selectedPresetMode = getPresetControlMode(selectedPresetControlNode); const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null; const selectedTextResourceNode = selectedNode && getDraftString(selectedNode, "action") === "TEXT_RESOURCE" ? selectedNode : null; const selectedTargetSourceNodes = useMemo( () => selectedNode && selectedTargetModuleKind ? edges .filter((edge) => edge.target === selectedNode.id) .map((edge) => nodes.find((item) => item.id === edge.source)) .filter((item): item is CloneNode => Boolean(item)) : [], [edges, nodes, selectedNode, selectedTargetModuleKind] ); 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]); const selectedVideoAnchor = useMemo(() => { if (!selectedVideoMediaNode) return null; return { bottomY: Math.round(viewport.y + (selectedVideoMediaNode.y + selectedVideoMediaNode.height) * viewport.zoom), centerX: Math.round(viewport.x + (selectedVideoMediaNode.x + selectedVideoMediaNode.width / 2) * viewport.zoom), topY: Math.round(viewport.y + selectedVideoMediaNode.y * viewport.zoom), zoom: viewport.zoom, }; }, [selectedVideoMediaNode, viewport.x, viewport.y, viewport.zoom]); const selectedGenericImageAnchor = useMemo(() => { if (!selectedGenericImageNode) return null; return { bottomY: Math.round(viewport.y + (selectedGenericImageNode.y + selectedGenericImageNode.height) * viewport.zoom), centerX: Math.round(viewport.x + (selectedGenericImageNode.x + selectedGenericImageNode.width / 2) * viewport.zoom), topY: Math.round(viewport.y + selectedGenericImageNode.y * viewport.zoom), zoom: viewport.zoom, }; }, [selectedGenericImageNode, viewport.x, viewport.y, viewport.zoom]); return (
{nodes.map((node) => ( openNodeContext(event, node)} onCancelGeneration={() => cancelGenerationNode(node.id)} onClearGridSplit={() => dismissNodeGridSplit(node.id)} onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)} onClearLocalEditRegions={() => clearNodeLocalEditRegions(node.id)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onPointerDown={(event) => startNodeDrag(event, node)} onSetLocalEditMode={(enabled) => setNodeLocalEditMode(node.id, enabled)} onSubmitLocalEdit={(regions) => submitNodeLocalEdit(node.id, regions)} onToggleGridSplitSelection={(index, behavior) => toggleNodeGridSplitSelection(node.id, index, behavior)} onUpdateLocalEditRegions={(regions) => updateNodeLocalEditRegions(node.id, regions)} 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 ? ( clearNodeGridSplitSelection(selectedPresetControlNode.id)} onGridSplitCreateNodes={() => createGridSplitImageNodes(selectedPresetControlNode.id)} onGridSplitUpscale={(scaleFactor) => upscaleSelectedGridSplit(selectedPresetControlNode.id, scaleFactor)} onComposerSubmit={(payload) => 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} {selectedGenericImageNode && selectedGenericImageAnchor ? (
runSelectedImageToolAction(selectedGenericImageNode.id, action, option)} />
) : null} {selectedVideoMediaNode && selectedVideoAnchor ? (
{ const nextMode = activeVideoToolbarMode === "analysis" ? null : "analysis"; setActiveVideoToolbarMode(nextMode); if (nextMode === "analysis") { void requestVideoAnalysis(selectedVideoMediaNode.id); } }} onDownload={() => downloadVideoNode(selectedVideoMediaNode)} onHd={() => toast.info("视频高清后端待接入,按钮先保留。")} onPreview={() => setPreviewNode(selectedVideoMediaNode)} onToggleClip={() => setActiveVideoToolbarMode((current) => current === "clip" ? null : "clip")} /> {activeVideoToolbarMode === "clip" ? ( applyVideoClipSelection(selectedVideoMediaNode.id)} onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)} onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })} /> ) : null} {activeVideoToolbarMode === "analysis" ? ( setActiveVideoToolbarMode(null)} /> ) : null}
) : 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} {selectedNode && selectedTargetModuleKind === "video" && selectedTargetAnchor ? ( { void submitVideoTargetComposer(selectedNode.id, payload); }} /> ) : null} toggleTopPanel("account")} onCommitTitle={commitTitle} onDraftTitleChange={setDraftTitle} onStartTitleEdit={() => setIsTitleEditing(true)} projectTitle={projectTitle} routerBack={() => router.push("/")} siteLogo={siteBrandSettings?.siteLogo} siteLogoDark={siteBrandSettings?.siteLogoDark} siteName={resolvedSiteName} /> { 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)} siteName={resolvedSiteName} /> ) : null} {activePanel === "share" ? ( setActivePanel(null)} onCopyLink={() => { void copyShareLink(); setActivePanel(null); }} onPublish={() => setActivePanel("publish")} siteName={resolvedSiteName} /> ) : null} {activePanel === "publish" ? ( setActivePanel(null)} onTogglePublic={() => setPublishPublic((value) => !value)} siteName={resolvedSiteName} /> ) : null} {activePanel === "member" ? ( setActivePanel(null)} /> ) : null} {activePanel === "notifications" ? ( setActivePanel(null)} /> ) : null} {activePanel === "account" ? ( setActivePanel(null)} onOpenMember={() => setActivePanel("member")} /> ) : 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 = resolveSiteName(siteName); 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 GridSplitActionToolbar({ anchor, billingLabel, onClearSelection, onCreateNodes, onUpscale, selectedCount, }: { anchor?: PresetControlAnchor; billingLabel: string; onClearSelection: () => void; onCreateNodes: () => void; onUpscale: (scaleFactor: number) => void; selectedCount: number; }) { const [scaleFactor, setScaleFactor] = useState(2); const [scaleMenuOpen, setScaleMenuOpen] = useState(false); return ( <>
已选 {selectedCount} 个宫格
{scaleMenuOpen ? (
{[2, 4].map((option) => ( ))}
) : null}
); } function VideoMediaToolbar({ activeMode, anchor, onAnalyze, onDownload, onHd, onPreview, onToggleClip, }: { activeMode: VideoToolbarMode; anchor?: PresetControlAnchor; onAnalyze: () => void; onDownload: () => void; onHd: () => void; onPreview: () => void; onToggleClip: () => void; }) { return (
} label="剪辑" onClick={onToggleClip} /> } label="高清" onClick={onHd} /> } label="解析" onClick={onAnalyze} />
); } function VideoToolbarActionButton({ active = false, disabled = false, icon, label, onClick, }: { active?: boolean; disabled?: boolean; icon: ReactNode; label: string; onClick: () => void; }) { return ( ); } function VideoAnalysisGlyph() { return ( ); } function VideoClipEditorBar({ anchor, clipRange, durationSeconds, onApply, onChange, onReset, previewUrl, title, }: { anchor?: PresetControlAnchor; clipRange: VideoClipRange; durationSeconds: number; onApply: () => void; onChange: (range: VideoClipRange) => void; onReset: () => void; previewUrl: string; title: string; }) { const trackRef = useRef(null); const [dragHandle, setDragHandle] = useState<"start" | "end" | null>(null); const frames = useMemo( () => Array.from({ length: VIDEO_CLIP_FRAME_COUNT }).map((_, index) => ({ id: `${title}-${index}`, url: previewUrl })), [previewUrl, title] ); useEffect(() => { if (!dragHandle) return; const handlePointerMove = (event: PointerEvent) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0) return; const ratio = Math.max(0, Math.min((event.clientX - rect.left) / rect.width, 1)); if (dragHandle === "start") { onChange({ end: clipRange.end, start: Math.min(ratio, clipRange.end - 0.04), }); return; } onChange({ end: Math.max(ratio, clipRange.start + 0.04), start: clipRange.start, }); }; const handlePointerUp = () => setDragHandle(null); window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; }, [clipRange.end, clipRange.start, dragHandle, onChange]); const selectionWidth = Math.max(clipRange.end - clipRange.start, 0.04); const durationLabel = formatClipDurationLabel(durationSeconds * selectionWidth); return (
event.stopPropagation()} ref={trackRef} >
{frames.map((frame, index) => (
{frame.url ? : null} {index !== frames.length - 1 ? : null}
))}
); } function VideoAnalysisFloatingPanel({ anchor, analysisState, node, onClose, }: { anchor?: PresetControlAnchor; analysisState: VideoAnalysisState; node: CloneNode; onClose: () => void; }) { const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || ""; const rows = analysisState.rows; const floatingLeft = anchor ? Math.max( 24, Math.min( typeof window !== "undefined" ? window.innerWidth - 884 : anchor.centerX + 380, anchor.centerX + 380 ) ) : undefined; return (
视频故事
{analysisState.summary}
{analysisState.error ? (
{analysisState.error}
) : null}
{["镜头描述", "背景音乐", "人声/音效", "图像生成提示词", "视频运动提示词", "关键帧"].map((item) => (
{item}
))}
{analysisState.status === "loading" && rows.length === 0 ? ( Array.from({ length: 2 }).map((_, index) => (
{Array.from({ length: 6 }).map((_, cell) => (
))}
)) ) : rows.length > 0 ? ( rows.map((row) => (
{row.sceneDescription}
{row.backgroundMusic}
{row.voiceAndSfx}
{row.imagePrompt}
{row.motionPrompt}
{row.keyframeUrl || previewUrl ? ( ) : (
无预览
)}
)) ) : (
暂无解析结果
)}
); } function PresetWorkflowControls({ anchor, functionConfig, membershipSummary, mode, modelOptions, nodes, onClearGridSplitSelection, onGridSplitCreateNodes, onGridSplitUpscale, onComposerSubmit, onImageToolAction, onPresetSubmit, selectedNode, }: { anchor: PresetControlAnchor; functionConfig: VideoCanvasFunctionConfig; membershipSummary?: MembershipSummaryResponse; mode: Exclude; modelOptions: CanvasModelOption[]; nodes: CloneNode[]; onClearGridSplitSelection: () => void; onGridSplitCreateNodes: () => void; onGridSplitUpscale: (scaleFactor: number) => void; onComposerSubmit: (payload: ComposerSubmitPayload) => void; onImageToolAction: (action: ImageToolbarAction, option?: string) => void; onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void; selectedNode: CloneNode; }) { if (isOfficialImagePresetFunctionKey(mode)) { const definition = getConfiguredFunction(functionConfig, mode); const gridSplit = getNodeGridSplitState(selectedNode); if (gridSplit && gridSplit.selectedIndices.length > 0) { return (
); } 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, billingDefinition, defaultModel = "auto", membershipSummary, modelOptions = [], node, onSubmit, }: { anchor: PresetControlAnchor; billingDefinition?: VideoCanvasFunctionDefinition; defaultModel?: string; membershipSummary?: MembershipSummaryResponse; 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]); const billingDisplay = useMemo( () => resolveVideoCanvasBillingDisplay({ functionDefinition: billingDefinition, membershipSummary, modelKind: "multimodal", modelOptions, modelRef: selectedModel, }), [billingDefinition, membershipSummary, modelOptions, selectedModel], ); return (
{menu === "model" ? ( { setSelectedModel(option); setMenu(null); }} /> ) : null}