"use client"; import { ArrowUp, Bell, BookOpen, BoxSelect, Camera, Check, ChevronDown, ChevronRight, Clock3, Copy, Download, Ellipsis, FileText, FolderOpen, GitBranch, Headphones, HelpCircle, Image as ImageIcon, Keyboard, Languages, Layers, Loader2, Maximize2, Minimize2, Music2, Move, Pause, Plus, Play, Redo2, RotateCcw, Search, Scissors, Settings2, SlidersHorizontal, Sparkles, Star, Trash2, Upload, UserRound, Video, Wand2, Undo2, Zap, X, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { createPortal } from "react-dom"; import { type ChangeEvent as ReactChangeEvent, type DragEvent as ReactDragEvent, Fragment, 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 type { CanvasInitialProject } from "@/types/canvas"; import { getVideoCanvasFunctionBillingCopy, type VideoCanvasFunctionConfig, type VideoCanvasFunctionDefinition, type VideoCanvasEffectPreset, type VideoCanvasMotionPreset, type VideoCanvasRolePreset, defaultVideoCanvasFunctionConfig, defaultVideoCanvasMotionPresets, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; type ActivePanel = | "add" | "workflow" | "assets" | "assetManager" | "effectLibrary" | "history" | "historyPicker" | "help" | "roleLibrary" | "videoRoleLibrary" | "shortcuts" | "styleLibrary" | "support" | "tutorial" | "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" | "mode" | "ratio" | "resolution" | "duration" | "camera" | "panorama" | "translate" | "settings" | "count" | "reference" | "slash" | null; type MemberStoreTab = "vip" | "history" | "credits"; type VideoToolbarMode = "clip" | "crop" | "subtitleErase" | "analysis" | null; type VideoToolbarMenu = "hd" | "subtitle" | "audio" | null; type VideoFrameExtractionKind = "first" | "last" | "current"; type SubtitleEraseMode = "smart" | "region"; type VideoAudioSplitMode = "voice" | "background" | "audio-video"; type SubtitleEraseRegion = { height: number; id: string; width: number; x: number; y: number; }; type SubtitleEraseRegionHistory = { future: SubtitleEraseRegion[][]; past: SubtitleEraseRegion[][]; }; 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 CanvasLibraryAsset = { label: string; mediaType: NonNullable; type: string; url: string; }; type WorkflowPreset = { id?: string | number; uuid?: string; name?: string; title?: string; description?: string; cover?: string; }; type ToolboxTemplatePreset = WorkflowPreset & { accent: string; author: string; badge?: string; category: string; duration: string; steps: string[]; templateKey: string; usageCount: string; }; type RoleLibraryItem = VideoCanvasRolePreset; type RoleLibraryFilterKey = keyof RoleLibraryItem["filters"]; type CloneNodeKind = | "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "material-effect" | "material-role" | "material-style" | "script" | "video-story"; 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 VideoCanvasMagicMark = { clickX: number; clickY: number; id: string; imageUrl: string; labelOptions: string[]; selectedIndex: number; sourceNodeId: string; }; type VideoCanvasMarkAnalysisState = { clickX: number; clickY: number; sourceNodeId: string; targetNodeId: string; }; type PickObjectApiResponse = { error?: string; object?: { confidence?: number; labels?: string[]; name?: string; }; success?: boolean; }; type AssetMarketCard = readonly [title: string, author: string, count: number, model?: string]; type LineConnectState = { cursorCanvasX: number | null; cursorCanvasY: number | null; hoverNodeId?: string | null; targetId?: string | null; sourceId: string | null; }; type EndpointConnectionDragState = { anchorId: string; hasDragged: boolean; pointerId: number | null; side: ConnectionSide; startClientX: number; startClientY: number; }; 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 StoryWorkflowStep = "shots" | "assets" | "prompts"; 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 = { autoValidateMaterial?: boolean; audio?: string; camera: string; count: string; duration?: string; model: string; modelLabel?: string; panorama: string; ratio: string; resolution: string; searchEnabled?: boolean; }; type VideoComposerTabKey = | "text-to-video" | "reference-images" | "first-frame" | "first-last-frame" | "image-reference"; type VideoTargetComposerSubmitPayload = { magicMarks: VideoCanvasMagicMark[]; 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" | "director" | "audio" | "script" | "legacy-script"; type CanvasCreateKind = AddModuleKind; type TargetModuleActionLabel = | "自己编写内容" | "文生视频" | "图片反推提示词" | "文字生音乐" | "图生图" | "图片高清" | "参考" | "标记" | "风格" | "音频生视频" | "首尾帧生成视频" | "首帧生成视频" | "自己编写分镜脚本" | "生成高清视频" | "打开导演台" | "剧本生成分镜脚本" | "视频参考生成分镜脚本" | "角色生成分镜脚本"; type ImageToolbarAction = | "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | "localEdit" | "annotate" | "rotate" | "download" | "preview"; type StoryToolbarAction = "regenerate" | "create-storyboard" | "batch-video"; type PresetMediaToolbarAction = | "first-frame-hd" | "first-frame-outpaint" | "first-frame-background" | "first-frame-download" | "audio-beat" | "audio-camera" | "audio-sfx" | "audio-download"; type ComposerSubmitPayload = { prompt: string; references: string[]; settings: ComposerSettings; slashScene: string | null; }; type LiblibVideoCanvasReplicaProps = { canvasId: string; initialProject?: CanvasInitialProject | null; 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; shot: string; startTime: string; endTime: string; duration: string; sceneDescription: string; narrative: string; shotSize: string; cameraAngle: string; cameraMovement: string; focusDepth: string; lighting: string; backgroundMusic: string; voiceAndSfx: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; type StoryScriptRow = { id: string; shot: string; duration: string; scene: string; sceneDescription: string; shotSize: string; lighting: string; dialogue: string; backgroundMusic: string; voiceAndSfx: string; cameraMovement: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; type VideoAnalysisResponsePayload = { analysisRows?: Array<{ backgroundMusic?: string; dialogue?: string; cameraAngle?: string; cameraMovement?: string; duration?: string | number; endTime?: string | number; focusDepth?: string; imagePrompt?: string; keyframeUrl?: string; lighting?: string; motionPrompt?: string; narrative?: string; sceneDescription?: string; scene?: string; shot?: string | number; shotSize?: string; startTime?: string | number; story?: string; voiceAndSfx?: string; }>; framePreviews?: string[]; highlights?: string[]; raw?: string; recommendedNextSteps?: string[]; shotIdeas?: Array<{ title: string; description: string }>; summary?: string; }; type VideoAnalysisResponseRow = NonNullable[number]; type VideoAnalysisState = { status: "idle" | "loading" | "ready" | "error"; summary: string; rows: VideoAnalysisRow[]; error?: string; }; type VideoCropGenerateState = { status: "loading"; progress: number; }; type VideoAudioSplitState = { status: "loading"; mode: VideoAudioSplitMode; }; 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: "director", label: "导演台", description: "3D空间多视角截图", icon: Settings2, badge: "NEW" }, { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, { kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "NEW" }, ]; const ADD_NODE_PRIMARY_MODULES = ADD_NODE_MODULES.filter((item) => item.kind !== "script"); const SCRIPT_SUBMENU_OPTIONS = [ { badge: "NEW", description: "新增脚本节点", label: "脚本", action: "script" as const }, { badge: "Beta", description: "旧版脚本节点", label: "脚本(旧版)", action: "legacy" as const }, ]; const ASSET_LIBRARY_SUBMENU_OPTIONS = [ { description: "新增风格节点", label: "风格库", action: "style" as const }, { description: "新增特效节点", label: "特效库", action: "effect" as const }, ]; 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 }, "legacy-script": { x: 556, y: 253 }, script: { x: 556, y: 253 }, text: { x: -742, y: -257 }, video: { x: -574, y: 253 }, "video-clip": { x: 80, y: 253 }, director: { x: 728, 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 TOOLBOX_TEMPLATE_CATEGORIES = ["周星驰经典名场面", "电商展示", "镜头运动", "人物动画", "全部"] as const; const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [ { accent: "linear-gradient(135deg,#f5d789,#f08b62)", author: "LibTV 官方", badge: "预设", category: "周星驰经典名场面", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。", duration: "5s", id: "left-arc-slide", name: "【预设】左弧滑行", steps: ["参考图", "镜头轨迹", "Seedance 2.0", "视频"], templateKey: "left-arc-slide", usageCount: "4.2k", }, { accent: "linear-gradient(135deg,#98d5ff,#6c84ff)", author: "电商模板库", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。", duration: "5s", id: "phone-pop-product", name: "电商手机弹出效果", steps: ["产品图", "弹出动画", "高光", "视频"], templateKey: "phone-pop-product", usageCount: "3.6k", }, { accent: "linear-gradient(135deg,#dfc192,#8e623c)", author: "商品展示", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。", duration: "5s", id: "coffee-cup-entry", name: "咖啡杯出场", steps: ["商品图", "场景光", "蒸汽", "视频"], templateKey: "coffee-cup-entry", usageCount: "2.8k", }, { accent: "linear-gradient(135deg,#d3e5ff,#9fb3cc)", author: "运镜模板", badge: "热门", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。", duration: "6s", id: "orbit-360-showcase", name: "360旋转展示", steps: ["主体图", "环绕镜头", "景深", "视频"], templateKey: "orbit-360-showcase", usageCount: "5.1k", }, { accent: "linear-gradient(135deg,#d7d8dc,#737d89)", author: "工业镜头", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png", description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。", duration: "5s", id: "robot-arm-pov", name: "机械臂视角", steps: ["参考图", "机械臂", "穿行", "视频"], templateKey: "robot-arm-pov", usageCount: "1.9k", }, { accent: "linear-gradient(135deg,#f9c8d8,#c693ff)", author: "人物动画", badge: "NEW", category: "人物动画", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png", description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。", duration: "4s", id: "live-2d-character", name: "Live 2D", steps: ["角色图", "表情", "轻动作", "视频"], templateKey: "live-2d-character", usageCount: "6.4k", }, { accent: "linear-gradient(135deg,#bad5ff,#2e375d)", author: "电影运镜", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。", duration: "5s", id: "pupil-zoom", name: "瞳孔拉近", steps: ["人物图", "特写", "反光", "转场"], templateKey: "pupil-zoom", usageCount: "2.2k", }, { accent: "linear-gradient(135deg,#f7e6b0,#ef6b62)", author: "创意玩法", badge: "热门", category: "人物动画", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。", duration: "6s", id: "bird-dissolve", name: "飞鸟解体", steps: ["主体图", "粒子", "重组", "视频"], templateKey: "bird-dissolve", usageCount: "3.4k", }, { accent: "linear-gradient(135deg,#f3db93,#ef5e50)", author: "电商模板库", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。", duration: "5s", id: "product-hero-entry", name: "商品震撼登场", steps: ["产品图", "冲击波", "高光", "视频"], templateKey: "product-hero-entry", usageCount: "4.8k", }, { accent: "linear-gradient(135deg,#a6e7d8,#6da3ff)", author: "旅拍模板", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。", duration: "5s", id: "travel-whip-transition", name: "旅拍转场", steps: ["首帧", "遮挡", "转场", "视频"], templateKey: "travel-whip-transition", usageCount: "2.6k", }, ]; const ROLE_LIBRARY_FILTER_GROUPS: Array<{ key: RoleLibraryFilterKey; label: string; options: string[]; }> = [ { key: "gender", label: "性别", options: ["男", "女", "中性"] }, { key: "age", label: "年龄段", options: ["儿童", "少年", "青年", "中年", "老年"] }, { key: "species", label: "种族", options: ["人类", "精灵", "兽人", "机械", "其他"] }, { key: "era", label: "时代", options: ["先秦", "古代", "近代", "现代", "未来"] }, { key: "culture", label: "文化区域", options: ["东亚", "欧美", "中东", "非洲", "奇幻"] }, { key: "build", label: "体型", options: ["瘦削", "匀称", "健壮", "丰满"] }, { key: "hairColor", label: "发色", options: ["黑发", "白发", "金发", "棕发", "彩色"] }, ]; const ROLE_LIBRARY_APPLY_VIEWPORT = { x: -3842.91, y: 487.096, zoom: 0.743079 }; const ROLE_LIBRARY_NODE_LAYOUT = [ { height: 350, imageKey: "full" as const, title: "角色立绘", width: 282, x: 5748, y: -564 }, { height: 350, imageKey: "face" as const, title: "脸部近景", width: 282, x: 6048, y: -564 }, { height: 350, imageKey: "expression" as const, title: "表情参考", width: 282, x: 6348, y: -564 }, { height: 495, imageKey: "threeView" as const, title: "三视图", width: 878, x: 5748, y: -156 }, ]; const MATERIAL_STYLE_NODE_SIZE = 242; const MATERIAL_STYLE_SCREEN_ANCHOR = { x: 666, y: 776 }; const VIDEO_ROLE_AGREEMENT_STORAGE_KEY = "video-canvas:seedance-role-agreement:v1"; const VIDEO_MOTION_CUSTOM_STORAGE_KEY = "video-canvas:custom-motions:v1"; const VIDEO_MOTION_FAVORITES_STORAGE_KEY = "video-canvas:favorite-motions:v1"; type VideoCanvasCustomMotionPreset = VideoCanvasMotionPreset & { custom: true; }; const LIBLIB_VIDEO_MODEL_OPTIONS = [ { label: "Seedance 2.0 VIP", duration: "2min", description: "最强视频模型,会员专属通道,15s音画同步", }, { label: "Seedance 2.0 Fast VIP", duration: "2min", description: "最强视频模型快速版,会员专属通道,15s音画同步", }, { label: "Seedance 2.0 Mini", duration: "2min", description: "最强视频模型mini版,高性价比生成,15s音画同步", }, { label: "Happy Horse 1.1", duration: "3min", description: "阿里最新视频模型,一致性与视听质量更可控", }, { label: "Happy Horse 1.0", duration: "3min", description: "阿里视频模型,支持多参生成", }, { label: "Kling O3", duration: "3min", description: "视频编辑模型、参考一致性、音画同出、多镜头", }, { label: "Kling 3.0 Turbo", duration: "3min", description: "视频生成模型,高质感、支持多镜头", }, { label: "Kling 3.0", duration: "3min", description: "视频生成模型,高质感、支持多镜头", }, { label: "Wan 2.7", duration: "3min", description: "全能参考,支持修改视频画面、剧情、环境", }, { label: "Kling O1", duration: "3min", description: "可灵一代编辑模型、支持多模态输入", }, { label: "Wan 2.6", duration: "3min", description: "音画同步,支持多机位镜头,最长可生15秒视频", }, { label: "Hailuo 2.3", duration: "2min", description: "善于表达动作、表情、镜头,更高质感", }, { label: "Seedance1.5 Pro", duration: "2min", description: "音画同步,支持多机位镜头,最长可生12秒视频", }, { label: "Seedance 1.0 Pro", duration: "2min", description: "高精度提示词理解,40秒生成1080P视频", }, { label: "Seedance 1.0 Lite", duration: "1min", description: "轻量快速,一键进行日常视频生成", }, { label: "Kling 2.6", duration: "2min", description: "视频生成模型、直出音画同步", }, { label: "Hailuo 02", duration: "2min", description: "画质稳定,适合打造运动特效场景", }, { label: "Vidu Q2", duration: "3min", description: "多图主体参考,精确控制效果佳", }, { label: "Vidu Q2 Pro", description: "Vidu Q2 Pro", }, { label: "Vidu Q2 Turbo", description: "Vidu Q2 Turbo", }, { label: "Vidu Q3 Pro", duration: "2min", description: "支持主体参考,精确控制效果佳", }, { label: "OmniHuman 1.5", duration: "3min", description: "多模态数字人视频生成", }, { label: "Kling 2.5", duration: "2min", description: "速度快、效果稳定、性价比高", }, { label: "Kling 2.1", duration: "3min", description: "支持首尾帧,图生视频效果更出色", }, { label: "Wan 2.2", duration: "3min", description: "支持特效、玩法千变万化", }, { label: "Wan 2.5", duration: "3min", description: "支持特效、直出音画同步", }, { label: "Pixverse V5.5", duration: "3min", description: "支持特效、玩法丰富", }, { label: "Pixverse V5", duration: "3min", description: "支持特效、玩法丰富", }, { label: "Hailuo 2.3 Fast", duration: "1min", description: "善于表达动作、表情、镜头,更快速", disabled: true, }, { label: "Kling3.0 动作迁移", duration: "8min", description: "动作控制模型,需输入1张图片、1条视频", disabled: true, }, { label: "MJ Video", duration: "2min", description: "图生视频效果稳定,画面表现力强", disabled: true, }, ] as const; const VIDEO_RATIO_OPTIONS = [ { label: "Auto", value: "adaptive" }, { label: "16:9", value: "16:9" }, { label: "4:3", value: "4:3" }, { label: "1:1", value: "1:1" }, { label: "3:4", value: "3:4" }, { label: "9:16", value: "9:16" }, { label: "21:9", value: "21:9" }, ] as const; const VIDEO_RESOLUTION_OPTIONS = ["480P", "720P", "1080P", "4K"] as const; const VIDEO_COUNT_OPTIONS = ["1个", "2个", "4个"] as const; const VIDEO_AUDIO_OPTIONS = ["开启", "关闭"] as const; const VIDEO_EFFECT_PRESETS = [ { title: "燃烧开场", author: "算法织梦者", count: 57 }, { title: "巨人俯瞰", author: "数据星云", count: 114 }, { title: "星尘降临", author: "代码极光", count: 235 }, { title: "星云漩涡", author: "算力潮汐", count: 57 }, { title: "水下慢镜头", author: "算法织梦者", count: 82 }, { title: "控雨术", author: "数据星云", count: 168 }, { title: "穿云而入", author: "代码极光", count: 93 }, { title: "双人对打", author: "算力潮汐", count: 211 }, { title: "饰品特写", author: "算法织梦者", count: 74 }, ] as const; const PRESET_MEDIA_TOOLBAR_ITEMS: Record = { "first-frame-hd": ["高清", "首帧高清接口待接入。"], "first-frame-outpaint": ["扩图", "首帧扩图接口待接入。"], "first-frame-background": ["替换背景", "背景替换接口待接入。"], "first-frame-download": ["下载", "图片下载接口待接入。"], "audio-beat": ["节奏匹配", "音频节奏解析接口待接入。"], "audio-camera": ["镜头语言", "镜头语言配置接口待接入。"], "audio-sfx": ["生成音效", "音效生成接口待接入。"], "audio-download": ["下载", "素材下载接口待接入。"], }; 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: "roleLibrary", label: "角色库", icon: UserRound }, { id: "history", label: "历史记录", icon: Clock3 }, { id: "shortcuts", label: "快捷键", icon: Keyboard }, { id: "tutorial", label: "教程", icon: BookOpen }, ]; 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 CANVAS_MIN_ZOOM = 0.1; const CANVAS_MAX_ZOOM = 8; const CANVAS_ZOOM_STEP = 0.2; const ASSET_MANAGER_DEFAULT_WIDTH = 240; const ASSET_MANAGER_MIN_WIDTH = 240; const ASSET_MANAGER_MAX_WIDTH = 520; const OFFICIAL_NODE_GAP = 100; const TOOLBOX_TEMPLATE_VIEWPORT = { x: 137.4, y: 529, zoom: 0.1 }; const TOOLBOX_TEMPLATE_GROUP_FRAME = { height: 658, width: 1493, x: 6010, y: -1440, }; const VIDEO_SUBTITLE_ERASE_SIMULATED_MS = 42_000; 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_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_MAX_POLL_ATTEMPTS = 192; const LOCAL_EDIT_COLORS = [ "#ff3b30", "#1677ff", "#00c853", "#ff9500", "#af52de", "#00bcd4", "#ff2d55", "#8d6e63", ]; const VIDEO_CLIP_FRAME_COUNT = 9; const VIDEO_CROP_INLINE_LABEL = "1024 x 576"; const VIDEO_CROP_OUTPUT_LABEL = "1024 × 576"; const VIDEO_CROP_RECT = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }; 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"; function clampCanvasZoom(value: number) { return Math.min(CANVAS_MAX_ZOOM, Math.max(CANVAS_MIN_ZOOM, Number(value.toFixed(2)))); } function clampAssetManagerWidth(value: number) { return Math.min(ASSET_MANAGER_MAX_WIDTH, Math.max(ASSET_MANAGER_MIN_WIDTH, Math.round(value))); } function arrangeNodesForPreview(nodes: CloneNode[]) { if (nodes.length <= 1) return nodes; const orderedNodes = [...nodes].sort((first, second) => first.y - second.y || first.x - second.x); const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length * 1.2))); const cellWidth = Math.max(360, ...nodes.map((node) => node.width)) + OFFICIAL_NODE_GAP; const cellHeight = Math.max(260, ...nodes.map((node) => node.height)) + OFFICIAL_NODE_GAP; const arrangedById = new Map>(); orderedNodes.forEach((node, index) => { const column = index % columns; const row = Math.floor(index / columns); arrangedById.set(node.id, { x: column * cellWidth, y: row * cellHeight, }); }); return nodes.map((node) => { const arranged = arrangedById.get(node.id); return arranged ? { ...node, ...arranged } : node; }); } 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岁),面色苍白,伏案加班。手机提示音不断。她抬头,视线模糊,黑屏。 【第一幕】 【盛唐 · 金銮殿 · 日】 鼓声震天。沈昭昭猛然睁眼,已跪在大殿中央,身着男装官服,双手被押。百官列立,殿宇巍峨。 大臣厉声:“大逆不道!假称诗才惊世,欺君罔上,当斩!” 沈昭昭震惊低语:“这是……唐朝?” 龙椅之上,皇帝目光沉沉。 皇帝:“既言才华盖世,当殿作诗。若不能,斩。” 刀光映入她瞳孔。大殿死寂。 【第二幕】 沈昭昭缓缓起身。呼吸渐稳。她望向殿外,长安城阳光万里。 她低声:“既来盛唐……便与盛唐争一争。” 抬头,高声吟诵:“君不见黄河之水天上来。” 声音回荡大殿。群臣震动。皇帝缓缓站起。诗声激昂,如惊雷落殿。 【第三幕】 殿外风起。长安街市灯火连绵。 皇帝低声:“此诗,当传万世。” 沈昭昭目光坚定。她轻声自语:“既然来了……那便写尽三万里长安。” 镜头推远。盛唐山河展开。`; const DEFAULT_STORY_SCRIPT_ROWS: StoryScriptRow[] = [ { id: "story-fallback-01", shot: "01", duration: "5s", scene: "现代办公室", sceneDescription: "深夜加班,键盘声急促,人物体力透支,电脑蓝光映在苍白脸上。", shotSize: "中景", lighting: "冷蓝灰低饱和,屏幕蓝光照亮脸部,压抑疲惫", dialogue: "+", backgroundMusic: "低频悬疑氛围", voiceAndSfx: "环境键盘声、手机提示音", cameraMovement: "缓慢推镜头", imagePrompt: "现代办公室深夜加班,年轻女性伏案,电脑蓝光,疲惫神情,电影级光影,近景", motionPrompt: "中近景缓慢推进,蓝光闪烁,人物抬头后画面黑屏转场。", keyframeUrl: "", }, { id: "story-fallback-02", shot: "02", duration: "6s", scene: "盛唐大殿", sceneDescription: "女主惊醒跪于金銮殿,百官压迫,刀光映入瞳孔,皇帝俯视审判。", shotSize: "全景", lighting: "强烈暖金主光,庄严压迫", dialogue: "既言才华盖世,当殿作诗。若不能--斩。", backgroundMusic: "鼓声与低频", voiceAndSfx: "朝堂回声、刀刃声", cameraMovement: "快速摇镜头,从下往上展现大殿", imagePrompt: "盛唐金銮殿,女主男装官服跪在殿中,百官列立压迫感,皇帝龙椅俯视,史诗电影感", motionPrompt: "低角度仰拍女主,再切皇帝俯视,镜头压迫式前推。", keyframeUrl: "", }, { id: "story-fallback-03", shot: "03", duration: "6s", scene: "长安街市", sceneDescription: "诗声落下,群臣震动,长安街市灯火连绵,盛唐山河展开。", shotSize: "大远景", lighting: "暖色夕光,极度开阔明亮", dialogue: "既然来了……那便写尽三万里长安。", backgroundMusic: "史诗音乐起", voiceAndSfx: "风声、街市人声、诗句回响", cameraMovement: "极速拉高、后退,展露全貌", imagePrompt: "盛唐长安街市灯火连绵,宫殿与山河远景展开,女主背影坚定,宏大史诗感,暖色夕光", motionPrompt: "镜头从人物背影快速拉远,展示长安城与山河全景。", keyframeUrl: "", }, ]; 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 getToolFunctionKey(tool?: CapturedTool) { const config = tool?.metadata?.config; const configuredKey = config && typeof config === "object" && !Array.isArray(config) ? (config as Record).functionKey : undefined; return String(configuredKey || tool?.toolKey || "").trim(); } function getToolRoute(tool?: CapturedTool) { const config = tool?.metadata?.config; const configuredRoute = config && typeof config === "object" && !Array.isArray(config) ? (config as Record).route : undefined; return String(configuredRoute || tool?.metadata?.route || "").trim(); } 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"; const toolConfig = tool.metadata?.config; const draftValues = toolConfig && typeof toolConfig === "object" && !Array.isArray(toolConfig) ? { ...toolConfig } : {}; 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 createToolboxTemplateGroupNode(title: string): CloneNode { return { id: makeId("group"), kind: "workflow", title: "分组 3 个节点", subtitle: title, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x, y: TOOLBOX_TEMPLATE_GROUP_FRAME.y, width: TOOLBOX_TEMPLATE_GROUP_FRAME.width, height: TOOLBOX_TEMPLATE_GROUP_FRAME.height, accent: "#ffffff", draftValues: { action: "TOOLBOX_TEMPLATE_GROUP", nodeType: "group", templateName: title, }, }; } 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: "脚本生成器", subtitle: "脚本生成", inputType: "text", x, y, width: 350, height: 350, accent: "#f0d06b", draftValues: { action: "SCRIPT_GENERATE", generatorType: "DEFAULT", nodeType: "script-v2", rows: [], scriptRole: "generator", viewMode: "card", }, }; } if (kind === "legacy-script") { return { id: makeId("script"), kind: "script", title: "脚本生成器", subtitle: "脚本生成", inputType: "text", x, y, width: 350, height: 350, accent: "#f0d06b", draftValues: { action: "SCRIPT_GENERATE", generatorType: "DEFAULT", nodeType: "legacy-script", rows: [], scriptRole: "generator", viewMode: "card", }, }; } if (kind === "video-clip") { return { id: makeId("video-clip"), kind: "model", title: `视频合成 ${index}`, subtitle: "视频合成", inputType: "video", x, y, width: 350, height: 350, accent: "#ffb8c8", draftValues: { action: "VIDEO_CLIP_RESOURCE", nodeType: "video-clip", params: {}, url: [], }, }; } if (kind === "director") { return { id: makeId("director"), kind: "model", title: `导演台 ${index}`, subtitle: "3D导演台", x, y, width: 350, height: 350, accent: "#9bd6ff", draftValues: { action: "DIRECTOR_CONSOLE_3D", consoleType: "director-console-3d", nodeType: "director", params: { activeCamera: "机位1", fov: 50, }, }, }; } 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 isSquareModule = item.nodeType === "text" || item.nodeType === "image" || item.nodeType === "audio"; const isVideoModule = item.nodeType === "video"; return { id: makeId(kind), kind: item.kind, title: item.title, subtitle: item.subtitle, inputType: item.nodeType, x, y, width: isSquareModule ? 350 : isVideoModule ? 622 : 436, height: isSquareModule || isVideoModule ? 350 : 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 createMaterialStyleNode({ author, count, model, thumbnailUrl, title, x, y, }: { author: string; count: number; model?: string; thumbnailUrl: string; title: string; x: number; y: number; }): CloneNode { return { id: makeId("style"), kind: "material-style", title: `素材-风格-${title}`, subtitle: "风格素材", inputType: "image", x, y, width: MATERIAL_STYLE_NODE_SIZE, height: MATERIAL_STYLE_NODE_SIZE, accent: "#09caf5", draftValues: { action: "MATERIAL_STYLE", author, generated: true, mediaType: "image", mediaUrl: thumbnailUrl, model: model || "", nodeType: "material-style", styleTitle: title, thumbnailUrl, usageCount: count, url: [thumbnailUrl], }, }; } function createMaterialEffectNode({ preset, x, y, }: { preset: VideoCanvasEffectPreset; x: number; y: number; }): CloneNode { return { id: makeId("effect"), kind: "material-effect", title: `素材-特效-${preset.title}`, subtitle: "视频特效素材", x, y, width: MATERIAL_STYLE_NODE_SIZE, height: MATERIAL_STYLE_NODE_SIZE, accent: "#09caf5", draftValues: { action: "MATERIAL_EFFECT", author: preset.author, commercial: preset.commercial, effectId: preset.id, effectPrompt: preset.prompt, effectTitle: preset.title, generated: true, mediaUrl: preset.thumbnailUrl, model: preset.model, nodeType: "material-effect", thumbnailUrl: preset.thumbnailUrl, usageCount: preset.usageCount, url: [preset.thumbnailUrl], }, }; } function createMaterialRoleNode({ preset, x, y, }: { preset: VideoCanvasRolePreset; x: number; y: number; }): CloneNode { return { id: makeId("role"), kind: "material-role", title: `素材-角色-${preset.name}`, subtitle: "视频角色素材", x, y, width: MATERIAL_STYLE_NODE_SIZE, height: MATERIAL_STYLE_NODE_SIZE, accent: "#09caf5", draftValues: { action: "MATERIAL_ROLE", assetId: preset.assetId, compliant: preset.compliant, generated: true, mediaUrl: preset.images.full, nodeType: "material-role", roleDescription: preset.description, roleId: preset.id, roleName: preset.name, rolePrompt: preset.prompt, roleTags: preset.tags, thumbnailUrl: preset.images.full, url: [preset.images.full], }, }; } function createHistoryDemoAssetNode({ id, mediaType, mediaUrl, title, }: { id: string; mediaType: NonNullable; mediaUrl: string; title: string; }): CloneNode { const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; return { id, kind: "media", title, subtitle: `${label}资源`, inputType: mediaType, x: 0, y: 0, width: mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420, height: mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280, accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", draftValues: { action: `${mediaType.toUpperCase()}_RESOURCE`, alt: title, generated: true, historyDemo: true, mediaType, mediaUrl, nodeType: mediaType, url: [mediaUrl], }, }; } const DEFAULT_IMAGE_HISTORY_NODES: CloneNode[] = [ ["history-demo-image-1", "角色图", CHARACTER_IMAGE_URL], ["history-demo-image-2", "角色三视图", CHARACTER_THREE_VIEW_URL], ["history-demo-image-3", "首帧", FIRST_FRAME_IMAGE_URL], ["history-demo-image-4", "音频参考图", AUDIO_REFERENCE_IMAGE_URL], ["history-demo-image-5", "高清图片", HD_IMAGE_SOURCE_URL], ["history-demo-image-6", "视频首帧", VIDEO_FIRST_FRAME_SOURCE_URL], ["history-demo-image-7", "首尾帧-首帧", VIDEO_FIRST_LAST_FIRST_URL], ["history-demo-image-8", "首尾帧-尾帧", VIDEO_FIRST_LAST_LAST_URL], ].map(([id, title, mediaUrl]) => createHistoryDemoAssetNode({ id, mediaType: "image", mediaUrl, title })); const DEFAULT_VIDEO_HISTORY_NODES: CloneNode[] = Array.from({ length: 10 }, (_, index) => createHistoryDemoAssetNode({ id: `history-demo-video-${index + 1}`, mediaType: "video", mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, title: `分镜视频-#${index + 1}`, }) ); const DEFAULT_AUDIO_HISTORY_NODES: CloneNode[] = Array.from({ length: 6 }, (_, index) => createHistoryDemoAssetNode({ id: `history-demo-audio-${index + 1}`, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, title: `示例音频-#${index + 1}`, }) ); const HISTORY_ASSET_ZOOM_LEVELS = [ { cardSize: 130, label: "75%" }, { cardSize: 144, label: "100%" }, { cardSize: 182, label: "125%" }, { cardSize: 248, label: "150%" }, ] as const; 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 isVideoEffectMaterialNode(node: CloneNode | null | undefined) { return Boolean( node && (node.kind === "material-effect" || getDraftString(node, "action") === "MATERIAL_EFFECT"), ); } function isVideoRoleMaterialNode(node: CloneNode | null | undefined) { return Boolean( node && (node.kind === "material-role" || getDraftString(node, "action") === "MATERIAL_ROLE"), ); } 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 normalizeVideoCanvasMagicMarks(value: unknown): VideoCanvasMagicMark[] { if (!Array.isArray(value)) return []; return value .map((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const record = item as Record; const sourceNodeId = String(record.sourceNodeId || "").trim(); const imageUrl = String(record.imageUrl || "").trim(); const labelOptions = Array.from( new Set( (Array.isArray(record.labelOptions) ? record.labelOptions : [record.label]) .map((label) => String(label || "").trim().slice(0, 8)) .filter(Boolean) ) ).slice(0, 4); if (!sourceNodeId || !imageUrl || labelOptions.length === 0) return null; const clickX = Math.max(0, Math.min(1, Number(record.clickX) || 0)); const clickY = Math.max(0, Math.min(1, Number(record.clickY) || 0)); const selectedIndex = Math.max( 0, Math.min(labelOptions.length - 1, Number(record.selectedIndex) || 0) ); return { clickX, clickY, id: String(record.id || `${sourceNodeId}_${labelOptions[0]}`).trim(), imageUrl, labelOptions, selectedIndex, sourceNodeId, } satisfies VideoCanvasMagicMark; }) .filter((item): item is VideoCanvasMagicMark => Boolean(item)); } function getVideoCanvasMagicMarks(node: CloneNode | null | undefined) { if (!node) return []; const params = getNodeParams(node); return normalizeVideoCanvasMagicMarks( node.draftValues?.magicMarks || params.magicMarks ); } 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 formatVideoTimestamp(seconds: number) { const normalized = Math.max(0, Math.round(seconds)); const minutes = Math.floor(normalized / 60); const remainder = normalized % 60; return `${minutes}:${String(remainder).padStart(2, "0")}`; } function formatAudioTimestamp(seconds: number) { const normalized = Math.max(0, Math.round(seconds)); const minutes = Math.floor(normalized / 60); const remainder = normalized % 60; return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`; } 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 formatVideoStoryNumber(value: unknown, fallback: number) { const text = String(value ?? "").trim(); if (text) return text.replace(/\s*秒$/i, "").replace(/\s*s$/i, ""); const numericValue = Number.isFinite(fallback) ? fallback : 0; return Number.isInteger(numericValue) ? String(numericValue) : String(Number(numericValue.toFixed(1))).replace(/\.0$/, ""); } function formatVideoStoryShot(value: unknown, index: number) { const text = String(value ?? "").trim(); return text || String(index + 1); } function cleanVideoStoryText(value: unknown, fallback: string) { const text = String(value ?? "").trim(); return text || fallback; } 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) : []; const videoDuration = Math.max(1, Math.round(getVideoNodeDurationSeconds(node))); const defaultEndTime = formatVideoStoryNumber(undefined, videoDuration); const summary = responseData?.summary?.trim() || ""; const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`; const createRow = ( row: Partial & Record, index: number, ): VideoAnalysisRow => { const sceneDescription = cleanVideoStoryText( row.sceneDescription || row.description || row.pictureDescription, index === 0 ? `${node.title} 开场镜头` : `镜头 ${index + 1}` ); const narrative = cleanVideoStoryText( row.narrative || row.story || row.storyContent || row.summary, sceneDescription ); const motionPrompt = cleanVideoStoryText( row.motionPrompt || row.videoPrompt, "The camera slowly pushes toward the main subject while the action continues naturally and smoothly." ); return { id: `${node.id}-analysis-${index}`, shot: formatVideoStoryShot(row.shot || row.shotNumber || row.index, index), startTime: formatVideoStoryNumber(row.startTime, index === 0 ? 0 : index * videoDuration), endTime: formatVideoStoryNumber(row.endTime, (index + 1) * videoDuration), duration: formatVideoStoryNumber(row.duration, videoDuration), sceneDescription, narrative, shotSize: cleanVideoStoryText(row.shotSize || row.frameSize, "中景至近景"), cameraAngle: cleanVideoStoryText(row.cameraAngle || row.angle, "平视"), cameraMovement: cleanVideoStoryText(row.cameraMovement || row.cameraMove, "缓慢推镜头 (Slow Zoom In)"), focusDepth: cleanVideoStoryText(row.focusDepth || row.depthOfField, "浅景深"), lighting: cleanVideoStoryText(row.lighting || row.light, "冷调低沉照明,主体光线柔和。"), backgroundMusic: cleanVideoStoryText(row.backgroundMusic || row.music, "无"), voiceAndSfx: cleanVideoStoryText(row.voiceAndSfx || row.audio || row.sfx, "无"), imagePrompt: cleanVideoStoryText(row.imagePrompt || row.sceneDescription, fallbackPrompt), motionPrompt, keyframeUrl: String(row.keyframeUrl || row.previewUrl || row.imageUrl || "").trim() || framePreviews[index % Math.max(framePreviews.length, 1)] || previewUrl, }; }; if (modelRows.length > 0) { return modelRows.slice(0, 8).map((row, index) => createRow(row, index)); } const highlights = responseData?.highlights || []; const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || []; const rows = shotIdeas.length > 0 ? shotIdeas.map((idea, index) => createRow( { backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入", imagePrompt: idea.description || fallbackPrompt, motionPrompt: responseData?.recommendedNextSteps?.[index] || "The camera moves smoothly around the subject and keeps the action continuous.", narrative: idea.description || fallbackPrompt, sceneDescription: idea.title || `镜头 ${index + 1}`, voiceAndSfx: highlights[index + 1] || "无", }, index ) ) : [ createRow( { backgroundMusic: highlights[0] || "低沉的氛围电子乐,带有缓慢的机械节拍感。", endTime: defaultEndTime, imagePrompt: fallbackPrompt, motionPrompt: "The subject moves subtly while the camera slowly dollies in, keeping the scene cinematic and continuous.", narrative: summary || "通过主要角色、环境和镜头运动呈现当前视频的叙事情绪。", sceneDescription: `${node.title} 开场镜头`, startTime: "0", voiceAndSfx: highlights[1] || "环境声与轻微动作声。", }, 0 ), ]; return rows; } function createVideoStoryAnalysisNode({ existingNode, framePreviews = [], sourceNode, rows, summary, error, }: { error?: string; existingNode?: CloneNode; framePreviews?: string[]; sourceNode: CloneNode; rows: VideoAnalysisRow[]; summary: string; }): CloneNode { const previewUrl = getVideoNodePreviewUrl(sourceNode) || getNodeMediaUrl(sourceNode) || ""; return { id: existingNode?.id || makeId("video-story"), kind: "video-story", title: "视频故事", subtitle: "视频解析结果", inputType: "text", x: existingNode?.x ?? Math.round(sourceNode.x - 720), y: existingNode?.y ?? Math.round(sourceNode.y + sourceNode.height + 620), width: existingNode?.width ?? 800, height: existingNode?.height ?? 400, accent: "#8b96ff", draftValues: { ...(existingNode?.draftValues || {}), action: "VIDEO_STORY_ANALYSIS", error: error || undefined, framePreviews, generated: true, keyframeUrl: previewUrl, mediaType: "text", nodeType: "video-story", rows, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, status: error ? "error" : "ready", summary, }, }; } function normalizeVideoHdResolutionOption(option: string) { const text = option.trim(); if (/^(1080P|2K|4K)$/i.test(text)) return text.toUpperCase(); return "1080P"; } function createVideoHdUpscaleNode({ existingNode, option, sourceNode, }: { existingNode?: CloneNode; option: string; sourceNode: CloneNode; }): CloneNode { const resolution = normalizeVideoHdResolutionOption(option); const sourceUrl = getNodeReferenceMediaUrl(sourceNode); return { id: existingNode?.id || makeId("video-hd"), kind: "model", title: `高清(${resolution})`, subtitle: "配置参数", inputType: "video", x: existingNode?.x ?? Math.round(sourceNode.x), y: existingNode?.y ?? Math.round(sourceNode.y + sourceNode.height + OFFICIAL_NODE_GAP), width: existingNode?.width ?? 350, height: existingNode?.height ?? 350, accent: "#8b96ff", draftValues: { ...(existingNode?.draftValues || {}), action: "VIDEO_HD_UPSCALE", frameInterpolation: option === "不补帧" ? "不补帧" : existingNode?.draftValues?.frameInterpolation || "不补帧", generated: existingNode?.draftValues?.generated || false, hdEngine: option === "Topazlabs" ? "Topazlabs" : existingNode?.draftValues?.hdEngine || "Topazlabs", hdResolution: resolution, mediaType: "video", nodeType: "video-hd", outputSizeLabel: resolution === "4K" ? "3840 × 2160" : resolution === "2K" ? "2560 × 1440" : "1920 × 1080", scale: option === "1x" ? "1x" : existingNode?.draftValues?.scale || "1x", sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, sourceUrl, url: existingNode?.draftValues?.url || [], }, }; } function createVideoCropResultNode({ existingIndex, sourceNode, }: { existingIndex: number; sourceNode: CloneNode; }): CloneNode { const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL; const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const width = sourceNode.width || 560; const height = sourceNode.height || 315; return { id: makeId("video"), kind: "media", title: `${sourceNode.title || "视频"}_裁剪`, subtitle: "裁剪中", inputType: "video", x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y + existingIndex * (height + OFFICIAL_NODE_GAP)), width, height, accent: "#8b96ff", draftValues: { action: "VIDEO_CROP", cropRect: VIDEO_CROP_RECT, durationSeconds: getVideoNodeDurationSeconds(sourceNode), generated: false, generationProgress: 37, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", outputSizeLabel: VIDEO_CROP_OUTPUT_LABEL, poster: previewUrl, sourceUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, uploadStatus: "uploading", url: [], }, }; } function createVideoSubtitleEraseNode({ existingIndex, mode = "smart", regions = [], sourceNode, }: { existingIndex: number; mode?: SubtitleEraseMode; regions?: SubtitleEraseRegion[]; sourceNode: CloneNode; }): CloneNode { const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL; const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const outputSizeLabel = getDraftString(sourceNode, "outputSizeLabel") || "1280 × 720"; const normalizedRegions = regions.map(({ height, width, x, y }) => ({ height: Number(height.toFixed(4)), width: Number(width.toFixed(4)), x: Number(x.toFixed(4)), y: Number(y.toFixed(4)), })); const isRegionErase = mode === "region"; return { id: makeId("video"), kind: "media", title: `视频一键去字幕-${sourceNode.title || "视频"}`, subtitle: isRegionErase ? "框选擦除" : "去字幕", inputType: "video", x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y - 450 + existingIndex * 390), width: 350, height: 350, accent: "#8b96ff", draftValues: { action: "VIDEO_SUBTITLE_ERASE", cost: 0, durationSeconds: getVideoNodeDurationSeconds(sourceNode), generated: false, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", mode: isRegionErase ? "Text" : "Subtitle", eraseMode: mode, eraseRatioLocation: normalizedRegions, erase_ratio_location: normalizedRegions, model: "volcano-subtitle-eraser", nodeType: "video", outputSizeLabel, poster: previewUrl, sourceUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, url: [], }, }; } function formatStoryShot(value: unknown, index: number) { const text = String(value || "").trim(); if (/^\d+$/.test(text)) return text.padStart(2, "0"); return text || String(index + 1).padStart(2, "0"); } function normalizeStoryScriptRow(row: unknown, index: number, nodeId: string): StoryScriptRow | null { if (Array.isArray(row)) { const cells = row.map((cell) => String(cell || "").trim()); const shot = formatStoryShot(cells[0], index); const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || ""); const duration = secondCellLooksLikeDuration ? cells[1] || "5s" : "5s"; const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`; const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene; const voiceAndSfx = (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无"; const cameraMovement = (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头"; return { id: `${nodeId}-story-row-${index}`, shot, duration, scene, sceneDescription, shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景", lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影", dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+", backgroundMusic: "无", voiceAndSfx, cameraMovement, imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription, motionPrompt: (secondCellLooksLikeDuration ? cells[9] || cameraMovement : cells[6]) || "保持镜头运动自然、连贯,围绕主体动作推进。", keyframeUrl: "", }; } if (!row || typeof row !== "object") return null; const record = row as Record; const shot = formatStoryShot(record.shot || record.id, index); const duration = String(record.duration || record.length || record.time || "5s").trim(); const scene = String(record.scene || record.title || record.sceneDescription || `镜头 ${shot}`).trim(); const sceneDescription = String( record.sceneDescription || record.description || record.summary || record.imagePrompt || scene ).trim(); const shotSize = String(record.shotSize || record.frameSize || record.sceneScale || "中景").trim(); const lighting = String(record.lighting || record.light || record.atmosphere || record.mood || "电影感光影").trim(); const dialogue = String(record.dialogue || record.dialog || record.narration || record.voiceover || record.line || "+").trim(); const backgroundMusic = String(record.backgroundMusic || record.music || "无").trim(); const voiceAndSfx = String(record.voiceAndSfx || record.audio || record.sfx || "无").trim(); const cameraMovement = String( record.cameraMovement || record.cameraMove || record.movement || record.lensMovement || "缓慢推镜头" ).trim(); const imagePrompt = String(record.imagePrompt || sceneDescription || scene).trim(); const motionPrompt = String( record.motionPrompt || record.videoPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。" ).trim(); const keyframeUrl = String(record.keyframeUrl || record.previewUrl || record.imageUrl || "").trim(); if (![scene, sceneDescription, imagePrompt, motionPrompt, backgroundMusic, voiceAndSfx].some(Boolean)) { return null; } return { id: `${nodeId}-story-row-${index}`, shot, duration, scene, sceneDescription, shotSize, lighting, dialogue, backgroundMusic, voiceAndSfx, cameraMovement, imagePrompt, motionPrompt, keyframeUrl, }; } function getStoryScriptRows(node: CloneNode | null | undefined): StoryScriptRow[] { const nodeId = node?.id || "story"; const rawRows = Array.isArray(node?.draftValues?.rows) ? node.draftValues.rows : []; const rows = rawRows .map((row, index) => normalizeStoryScriptRow(row, index, nodeId)) .filter((row): row is StoryScriptRow => Boolean(row)); if (rows.length > 0) return rows; return DEFAULT_STORY_SCRIPT_ROWS.map((row, index) => ({ ...row, id: `${nodeId}-story-fallback-${index}`, })); } type StoryWorkflowAsset = { description: string; imageUrl: string; name: string; type: "role" | "scene" | "prop"; }; const STORY_WORKFLOW_ASSET_FALLBACKS: StoryWorkflowAsset[] = [ { description: "现代办公室加班女性,穿越前状态,疲惫压抑。", imageUrl: SCRIPT_CHARACTER_ONE_URL, name: "现代沈昭昭", type: "role", }, { description: "盛唐男装官服女主,朝堂危局中的核心角色。", imageUrl: SCRIPT_CHARACTER_TWO_URL, name: "沈昭昭", type: "role", }, { description: "龙椅之上威严注视的盛唐皇帝。", imageUrl: CHARACTER_IMAGE_URL, name: "皇帝", type: "role", }, { description: "恢弘金色朝堂,百官列立,压迫感强。", imageUrl: FIRST_FRAME_IMAGE_URL, name: "金銮殿", type: "scene", }, { description: "晴朗盛唐长安,街市与山河展开。", imageUrl: VIDEO_FIRST_FRAME_SOURCE_URL, name: "长安城白昼", type: "scene", }, { description: "夜色长安灯火连绵,结尾史诗气氛。", imageUrl: AUDIO_REFERENCE_IMAGE_URL, name: "长安城夜景", type: "scene", }, { description: "侍卫拔刀形成威胁,刀光映入角色瞳孔。", imageUrl: FIRST_FRAME_IMAGE_URL, name: "侍卫长刀", type: "prop", }, ]; function classifyStoryWorkflowAsset(name: string): StoryWorkflowAsset["type"] { if (/殿|城|长安|办公室|街|山河|夜景|白昼|场景/.test(name)) return "scene"; if (/刀|剑|笔|卷|书|道具/.test(name)) return "prop"; return "role"; } function getStoryWorkflowAssetImage(type: StoryWorkflowAsset["type"], index: number) { if (type === "role") { return [SCRIPT_CHARACTER_ONE_URL, SCRIPT_CHARACTER_TWO_URL, CHARACTER_IMAGE_URL][index % 3]; } if (type === "scene") { return [FIRST_FRAME_IMAGE_URL, VIDEO_FIRST_FRAME_SOURCE_URL, AUDIO_REFERENCE_IMAGE_URL][index % 3]; } return [FIRST_FRAME_IMAGE_URL, CHARACTER_IMAGE_URL][index % 2]; } function normalizeStoryWorkflowAssetName(rawName: string) { const knownAsset = STORY_WORKFLOW_ASSET_FALLBACKS.find((asset) => rawName.includes(asset.name)); if (knownAsset) return knownAsset.name; return rawName .replace(/(伏案|抬头|猛然|跪|目光|缓缓|站直|吟诵|转身|低声|满脸|发现|身着|看向|穿过|指着|厉声|内|外).*/, "") .trim() .slice(0, 8); } function getStoryWorkflowAssetGroups(rows: StoryScriptRow[]) { const detectedNames = Array.from( new Set( rows.flatMap((row) => [ row.scene, row.sceneDescription, row.dialogue, row.voiceAndSfx, row.imagePrompt, row.motionPrompt, ] .join(" ") .match(/@([\u4e00-\u9fa5A-Za-z0-9_-]{2,18})/g) ?.map((item) => normalizeStoryWorkflowAssetName(item.slice(1))) .filter(Boolean) || [] ) ) ); const detectedAssets = detectedNames.map((name, index) => { const type = classifyStoryWorkflowAsset(name); return { description: type === "role" ? "由脚本中 @ 引用自动抽取的角色资产。" : type === "scene" ? "由脚本镜头引用自动抽取的场景资产。" : "由镜头动作和威胁物件抽取的道具资产。", imageUrl: getStoryWorkflowAssetImage(type, index), name, type, }; }); const byName = new Map(); [...detectedAssets, ...STORY_WORKFLOW_ASSET_FALLBACKS].forEach((asset) => { if (!byName.has(asset.name)) byName.set(asset.name, asset); }); const assets = Array.from(byName.values()); return { prop: assets.filter((asset) => asset.type === "prop"), role: assets.filter((asset) => asset.type === "role"), scene: assets.filter((asset) => asset.type === "scene"), }; } function buildStorySceneText(row: StoryScriptRow) { return [ `镜号 ${row.shot}|${row.duration}|${row.scene}`, "", `画面:${row.sceneDescription}`, `景别:${row.shotSize}`, `光影氛围:${row.lighting}`, `对白/旁白:${row.dialogue}`, `音效:${row.voiceAndSfx}`, `运镜:${row.cameraMovement}`, `图像提示词:${row.imagePrompt}`, `运动提示词:${row.motionPrompt}`, `声音:${[row.backgroundMusic, row.voiceAndSfx].filter((item) => item && item !== "无").join(" / ") || "无"}`, ].join("\n"); } function buildStoryVideoPrompt(row: StoryScriptRow) { const motionPrompt = row.motionPrompt.includes("起始状态") ? row.motionPrompt : `起始状态:${row.sceneDescription} 动作过程:${row.cameraMovement},角色动作自然连贯。 结束状态:镜头停留在关键情绪或剧情转折点。 音效:${row.voiceAndSfx}`; return [ row.imagePrompt, motionPrompt, "[视觉风格:盛唐·国风二次元]", ].filter(Boolean).join("\n\n"); } function sanitizeDownloadFilename(value: string) { return (value.trim() || "story-script").replace(/[\\/:*?"<>|]+/g, "-").slice(0, 80); } function downloadStoryScriptRows(node: CloneNode, rows: StoryScriptRow[]) { const payload = { exportedAt: new Date().toISOString(), sourceNodeId: node.id, title: node.title, rows, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${sanitizeDownloadFilename(node.title)}-story-script.json`; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); } function StoryScriptWorkflowDialog({ node, onClose, onRun, onStepChange, step, }: { node: CloneNode; onClose: () => void; onRun: () => void; onStepChange: (step: StoryWorkflowStep) => void; step: StoryWorkflowStep; }) { const rows = getStoryScriptRows(node); const assetGroups = getStoryWorkflowAssetGroups(rows); const assetCount = assetGroups.role.length + assetGroups.scene.length + assetGroups.prop.length; const [promptRow, setPromptRow] = useState(null); const steps: Array<{ key: StoryWorkflowStep; label: string; subLabel: string }> = [ { key: "shots", label: "确认镜头", subLabel: `${rows.length}个镜头已就绪` }, { key: "assets", label: "准备资产", subLabel: `${assetCount}/${assetCount} 已生成` }, { key: "prompts", label: "合成提示词", subLabel: `${rows.length}/${rows.length} 已合成` }, ]; const tableColumns = [ "镜号", "时长", "画面描述", "景别", "光影氛围", "对白·旁白", "音效", "运镜", "最终提示词", "操作", ]; const gridTemplateColumns = "56px 56px 310px 72px 150px 140px 130px 128px 260px 162px"; const renderShotTable = (highlightPrompt = false) => (
{tableColumns.map((item) => (
{item}
))}
{rows.map((row) => (
{[ row.shot, row.duration, row.sceneDescription, row.shotSize, row.lighting, row.dialogue, row.voiceAndSfx, row.cameraMovement, row.imagePrompt, ].map((cell, index) => (
{cell}
))}
))}
); const renderAssetGroup = ( title: string, assets: StoryWorkflowAsset[], emptyLabel: string, ) => (

{title}

{assets.map((asset) => (
{asset.name} {asset.description}
{["替换图片", "AI 生角色", "清除图片"].map((item) => (
{item}
))}
删除
))}
); return (
event.stopPropagation()} >
{node.title || "脚本生成器"}
{steps.map((item, index) => { const active = item.key === step; return ( ); })}
3/3 完成后可批量生视频
{step === "shots" ? renderShotTable(false) : null} {step === "assets" ? (
全局风格 盛唐·国风二次元
{renderAssetGroup("角色", assetGroups.role, "新增")} {renderAssetGroup("场景", assetGroups.scene, "新增")} {renderAssetGroup("道具", assetGroups.prop, "新增")}
) : null} {step === "prompts" ? renderShotTable(true) : null}
{step === "assets" ? ( 资产已生成,如再次生成将会覆盖之前的图片/场景/道具等资产 ) : step === "prompts" ? ( 最终提示词已合成,可继续查看单镜提示词或重新批量合成 ) : ( )} {step === "shots" ? ( ) : null} {step === "assets" ? ( ) : null} {step === "prompts" ? ( ) : null}
{promptRow ? (

第 {promptRow.shot} 镜:最终提示词

分镜提示词

分镜图提示词·已生成

{promptRow.imagePrompt.includes("[视觉风格") ? promptRow.imagePrompt : `${promptRow.imagePrompt} [视觉风格:盛唐·国风二次元]`}

视频运动提示词

视频运动提示词·已生成

{promptRow.motionPrompt.includes("起始状态") ? promptRow.motionPrompt : `起始状态:${promptRow.sceneDescription} 动作过程:${promptRow.cameraMovement},角色动作自然连贯。 结束状态:镜头停留在关键情绪或剧情转折点。 音效:${promptRow.voiceAndSfx} [视觉风格:盛唐·国风二次元]`}

GVLM 3.1 Flash
消耗 2
) : null}
); } 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 normalizeVideoStoryRow(row: unknown, index: number, nodeId: string): VideoAnalysisRow | null { if (!row || typeof row !== "object") return null; const record = row as Partial & Record; const sceneDescription = cleanVideoStoryText(record.sceneDescription || record.description, `镜头 ${index + 1}`); return { id: String(record.id || `${nodeId}-video-story-row-${index}`), shot: formatVideoStoryShot(record.shot, index), startTime: cleanVideoStoryText(record.startTime, index === 0 ? "0" : ""), endTime: cleanVideoStoryText(record.endTime, record.duration || ""), duration: cleanVideoStoryText(record.duration, ""), sceneDescription, narrative: cleanVideoStoryText(record.narrative || record.story, sceneDescription), shotSize: cleanVideoStoryText(record.shotSize, "中景至近景"), cameraAngle: cleanVideoStoryText(record.cameraAngle, "平视"), cameraMovement: cleanVideoStoryText(record.cameraMovement, "缓慢推镜头 (Slow Zoom In)"), focusDepth: cleanVideoStoryText(record.focusDepth, "浅景深"), lighting: cleanVideoStoryText(record.lighting, "冷调低沉照明,主体光线柔和。"), backgroundMusic: cleanVideoStoryText(record.backgroundMusic, "无"), voiceAndSfx: cleanVideoStoryText(record.voiceAndSfx, "无"), imagePrompt: cleanVideoStoryText(record.imagePrompt, sceneDescription), motionPrompt: cleanVideoStoryText(record.motionPrompt, "The camera moves smoothly and keeps the motion continuous."), keyframeUrl: cleanVideoStoryText(record.keyframeUrl || record.previewUrl || record.imageUrl, ""), }; } function getVideoStoryRows(node: CloneNode | null | undefined): VideoAnalysisRow[] { const rawRows = Array.isArray(node?.draftValues?.rows) ? node.draftValues.rows : []; return rawRows .map((row, index) => normalizeVideoStoryRow(row, index, node?.id || "video-story")) .filter((row): row is VideoAnalysisRow => Boolean(row)); } 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 getConfiguredToolType(definition: VideoCanvasFunctionDefinition) { if (definition.kind === "audio") return "audio"; if (definition.kind === "video" || definition.route === "canvas-generate-video") return "video"; if (definition.kind === "workflow") return "compose"; if (definition.kind === "multimodal" || definition.route === "canvas-workflow-analyze") return "text"; return "image"; } function functionRequiresMedia(definition: VideoCanvasFunctionDefinition) { if (definition.route === "local") return false; if (definition.key === "text-to-video" || definition.key === "story-script") return false; return ( definition.kind === "image-edit" || definition.kind === "utility" || definition.key.includes("frame") || definition.key.includes("audio") || definition.route === "canvas-generate-image" || definition.route === "canvas-generate-video" || definition.route === "angle-edit" ); } function createConfiguredToolCatalog(config: VideoCanvasFunctionConfig): CapturedTool[] { return Object.values(config.functions) .filter((definition) => definition.enabled) .map((definition) => ({ metadata: { config: { functionKey: definition.key, model: definition.model || "", prompt: definition.prompt, route: definition.route, }, properties: { prompt: { default: definition.prompt, displayName: "提示词" }, specifiedModel: { default: definition.model || "", displayName: "模型引用", }, }, route: definition.route, }, modelKey: definition.model || definition.key, modelName: definition.label, modes: [definition.kind, definition.route], rules: functionRequiresMedia(definition) ? [{ require: ["media"] }] : [], scene: definition.description, toolKey: definition.key, type: getConfiguredToolType(definition), vendor: "视频画布配置", })); } function getToolReferenceModuleTemplates( tool: CapturedTool | undefined, definition: VideoCanvasFunctionDefinition, ): Array<{ kind: AddModuleKind; title?: string }> { if (!requiresMedia(tool) && !functionRequiresMedia(definition)) return []; if (definition.key === "video-analysis") return [{ kind: "video", title: "视频参考" }]; if (definition.key === "audio-to-video") { return [ { kind: "audio", title: "音频参考" }, { kind: "image", title: "画面参考" }, ]; } if (definition.key === "first-last-frame-video") { return [ { kind: "image", title: "首帧参考" }, { kind: "image", title: "尾帧参考" }, ]; } if (definition.route === "canvas-generate-video" && definition.key !== "text-to-video") { return [{ kind: "image", title: "视频参考图" }]; } return [{ kind: "image", title: "图片参考" }]; } function getToolReferenceMediaSpec( template: { kind: AddModuleKind; title?: string }, definition: VideoCanvasFunctionDefinition, index: number, ): { action: string; height: number; mediaType: NonNullable; mediaUrl: string; title: string; width: number; } { const title = template.title || "素材参考"; if (template.kind === "audio") { return { action: "AUDIO_RESOURCE", height: 190, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, title, width: 720, }; } if (template.kind === "video") { return { action: "VIDEO_RESOURCE", height: 315, mediaType: "video", mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, title, width: 560, }; } let mediaUrl = CHARACTER_IMAGE_URL; if (definition.key === "first-frame-video") mediaUrl = VIDEO_FIRST_FRAME_SOURCE_URL; if (definition.key === "first-last-frame-video") { mediaUrl = index === 0 ? VIDEO_FIRST_LAST_FIRST_URL : VIDEO_FIRST_LAST_LAST_URL; } if (definition.key === "audio-to-video") mediaUrl = AUDIO_REFERENCE_IMAGE_URL; if (definition.key === "image-to-prompt") mediaUrl = FIRST_FRAME_IMAGE_URL; if (definition.key === "hd-upscale") mediaUrl = HD_IMAGE_SOURCE_URL; return { action: "IMAGE_RESOURCE", height: definition.key === "audio-to-video" ? 400 : 280, mediaType: "image", mediaUrl, title, width: definition.key === "audio-to-video" ? 720 : 420, }; } 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" || ratio === "Auto" ) { return ratio === "Auto" ? "adaptive" : ratio; } return "16:9"; } function formatVideoRatioLabel(value: unknown) { const ratio = normalizeVideoRatioValue(value); return ratio === "adaptive" ? "Auto" : ratio; } function normalizeVideoResolutionValue(value: unknown): VideoResolution { const normalized = String(value || "") .trim() .toLowerCase() .replace(/\s+/g, ""); if (normalized === "480p") return "480p"; if (normalized === "1080p" || normalized === "1k" || normalized === "4k") 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 getVideoModeIcon(tab: VideoComposerTabKey): LucideIcon { if (tab === "text-to-video") return FileText; if (tab === "reference-images") return Sparkles; if (tab === "first-frame") return ImageIcon; if (tab === "first-last-frame") return Layers; return Wand2; } 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 === "director" || nodeType === "audio" || nodeType === "script-v2" || nodeType === "legacy-script" || nodeType === "script" ) { return nodeType === "script-v2" ? "script" : (nodeType as AddModuleKind); } 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 === "director" || nodeType === "audio" || nodeType === "script") { return nodeType; } if (nodeType === "script-v2") return "script"; if (nodeType === "legacy-script") return "legacy-script"; if (nodeType === "video-clip" || nodeType === "video_clip" || nodeType === "videoGroup") return "video-clip"; if (nodeType === "director-console-3d" || nodeType === "director_console_3d") return "director"; 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 (/导演台|director|console_3d|console-3d/.test(haystack)) return "director"; 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); if (kind === "director") return !/^导演台\s*\d+$/.test(value); if (kind === "script") return value !== "脚本生成器"; if (kind === "legacy-script") return 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, initialProject = null, isLoading = false, }: LiblibVideoCanvasReplicaProps) { const router = useRouter(); const canvasRef = useRef(null); const connectionDragCleanupRef = useRef<(() => void) | null>(null); const connectionDragRef = useRef(null); const assetManagerResizeCleanupRef = useRef<(() => void) | null>(null); const dragRef = useRef(null); const suppressConnectionClickRef = useRef(false); 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 configuredTools = useMemo( () => createConfiguredToolCatalog(videoCanvasFunctionConfig), [videoCanvasFunctionConfig] ); 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 [selectedEdgeId, setSelectedEdgeId] = useState(null); const [lineConnectState, setLineConnectState] = useState(null); const [referenceSelectionTargetId, setReferenceSelectionTargetId] = useState(null); const [markSelectionTargetId, setMarkSelectionTargetId] = useState(null); const [markAnalysisState, setMarkAnalysisState] = useState(null); const [effectLibraryTargetNodeId, setEffectLibraryTargetNodeId] = useState(null); const [roleLibraryTargetNodeId, setRoleLibraryTargetNodeId] = useState(null); const [videoRoleAgreementOpen, setVideoRoleAgreementOpen] = useState(false); const [videoRoleAgreementAccepted, setVideoRoleAgreementAccepted] = useState(false); 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 [assetManagerWidth, setAssetManagerWidth] = useState(ASSET_MANAGER_DEFAULT_WIDTH); const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false); 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 [cancelGenerationConfirmNodeId, setCancelGenerationConfirmNodeId] = useState(null); const [storyWorkflowNodeId, setStoryWorkflowNodeId] = useState(null); const [storyWorkflowStep, setStoryWorkflowStep] = useState("shots"); const [directorConsoleNodeId, setDirectorConsoleNodeId] = useState(null); const [directorTutorialActive, setDirectorTutorialActive] = useState(false); const [angleEditorNodeId, setAngleEditorNodeId] = useState(null); const [inspectorNodeId, setInspectorNodeId] = useState(null); const [dockOverviewOpen, setDockOverviewOpen] = useState(false); const [edgesHidden, setEdgesHidden] = useState(false); const [snapEnabled, setSnapEnabled] = useState(true); const [arrangePreview, setArrangePreview] = useState<{ nodes: CloneNode[]; viewport: { x: number; y: number; zoom: number }; } | null>(null); const [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState(null); const [subtitleEraseMode, setSubtitleEraseMode] = useState("smart"); const [subtitleEraseRegions, setSubtitleEraseRegions] = useState>({}); const [subtitleEraseRegionHistory, setSubtitleEraseRegionHistory] = useState>({}); const [videoClipRanges, setVideoClipRanges] = useState>({}); const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); const [videoCropGenerateStates, setVideoCropGenerateStates] = useState>({}); const [videoAudioSplitStates, setVideoAudioSplitStates] = useState>({}); useEffect(() => () => assetManagerResizeCleanupRef.current?.(), []); useEffect(() => { try { setVideoRoleAgreementAccepted( window.localStorage.getItem(VIDEO_ROLE_AGREEMENT_STORAGE_KEY) === "1", ); } catch { setVideoRoleAgreementAccepted(false); } }, []); 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 directorConsoleNode = useMemo( () => nodes.find((node) => node.id === directorConsoleNodeId) || null, [directorConsoleNodeId, nodes] ); const storyWorkflowNode = useMemo( () => nodes.find((node) => node.id === storyWorkflowNodeId) || null, [nodes, storyWorkflowNodeId] ); useEffect(() => { if (!directorTutorialActive) return; const handleEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); setDirectorTutorialActive(false); }; window.addEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape); }, [directorTutorialActive]); 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); setSubtitleEraseMode("smart"); }, [selectedVideoMediaNode?.id]); useEffect(() => { let active = true; const hydrateProject = (project: { canvasData?: unknown; title?: string | null; }) => { const title = normalizeCanvasTitle(project.title); setProjectTitle(title); setDraftTitle(title); setNodes(restoreNodes(project.canvasData)); setEdges(restoreEdges(project.canvasData)); }; async function loadProject() { setIsProjectLoading(true); const hasInitialProject = initialProject?.id === canvasId; try { if (hasInitialProject) { hydrateProject(initialProject); } const result = await canvasService.getProject(canvasId); if (!active) return; if (result.success && result.data) { hydrateProject(result.data); } else if (!hasInitialProject) { setProjectTitle("未命名"); setDraftTitle("未命名"); const seedResult = await canvasService.saveCanvasState( canvasId, { nodes: [], edges: [], selectedNodeIds: [], viewport: DEFAULT_VIEWPORT, tool: "select", }, "video" ); if (!seedResult.success) { console.warn("Failed to seed blank video canvas", seedResult.error); } } } catch (error) { console.warn("Failed to load video canvas project", error); } finally { setIsProjectLoading(false); } } void loadProject(); return () => { active = false; }; }, [canvasId, initialProject]); 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]); setSelectedEdgeId(null); const last = nextNodes[nextNodes.length - 1]; if (last) setSelectedNodeId(last.id); }, []); const sendDirectorSnapshotsToCanvas = useCallback( (directorNode: CloneNode, snapshots: Array<{ id: string; title: string }>) => { if (snapshots.length === 0) return; const nextNodes = snapshots.map((snapshot, index) => { const created = createMediaResourceNode({ action: "DIRECTOR_CAMERA_SNAPSHOT", height: DIRECTOR_CAMERA_SNAPSHOT_NODE_HEIGHT, mediaType: "image", mediaUrl: FIRST_FRAME_IMAGE_URL, title: `${directorNode.title} ${snapshot.title}`, width: DIRECTOR_CAMERA_SNAPSHOT_NODE_WIDTH, x: Math.round(directorNode.x + DIRECTOR_CAMERA_SNAPSHOT_NODE_OFFSET_X * (index + 1)), y: directorNode.y, }); return { ...created, draftValues: { ...(created.draftValues || {}), generated: true, sourceCameraSnapshotId: snapshot.id, sourceCameraSnapshotTitle: snapshot.title, sourceDirectorNodeId: directorNode.id, }, }; }); const nextEdges = nextNodes.map((snapshotNode) => ({ id: makeId("edge"), source: directorNode.id, target: snapshotNode.id, })); addNodes(nextNodes, nextEdges); setSelectedNodeId(directorNode.id); }, [addNodes] ); 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 removeGenerationNode = 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)); setSelectedEdgeId(null); }, []); const cancelGenerationNode = useCallback((nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (node && getDraftString(node, "generationStatus") === "pending") { setCancelGenerationConfirmNodeId(nodeId); return; } removeGenerationNode(nodeId); }, [nodes, removeGenerationNode]); useEffect(() => { if (!cancelGenerationConfirmNodeId) return; const node = nodes.find((item) => item.id === cancelGenerationConfirmNodeId); if (!node || getDraftString(node, "generationStatus") !== "pending") { setCancelGenerationConfirmNodeId(null); } }, [cancelGenerationConfirmNodeId, nodes]); const addWorkflowPreset = useCallback( (workflow: WorkflowPreset, index = 0) => { const title = String(workflow.name || workflow.title || "工具箱预设"); const group = createToolboxTemplateGroupNode(title); const reference = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: 525, mediaType: "image", mediaUrl: workflow.cover || FIRST_FRAME_IMAGE_URL, title: "参考图", width: 350, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x + 40, y: TOOLBOX_TEMPLATE_GROUP_FRAME.y + 40, }); const prompt = createTextResourceNode({ content: workflow.description || "Cinematic orbit shot. The camera starts from the front and smoothly pans in a large arc around the subject, keeping expressive eye contact and a playful rhythm.", height: 200, title: "提示词", width: 350, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x + 525, y: TOOLBOX_TEMPLATE_GROUP_FRAME.y + 45, }); const result = createMediaResourceNode({ action: "VIDEO_GENERATE", height: 525, mediaType: "video", mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, prompt: workflow.description || `${title} 预设工作流`, title: "成品", width: 350, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x + 1104, y: TOOLBOX_TEMPLATE_GROUP_FRAME.y + 93, }); 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, templateAuthor: (workflow as ToolboxTemplatePreset).author || "", templateCategory: (workflow as ToolboxTemplatePreset).category || "", templateKey: (workflow as ToolboxTemplatePreset).templateKey || "", templateSteps: (workflow as ToolboxTemplatePreset).steps || [], }, sourceNodeId: reference.id, templateAuthor: (workflow as ToolboxTemplatePreset).author || "", templateCategory: (workflow as ToolboxTemplatePreset).category || "", templateKey: (workflow as ToolboxTemplatePreset).templateKey || "", workflow, }, }; addNodes( [group, reference, prompt, presetNode], [ { id: makeId("edge"), source: reference.id, target: prompt.id }, { id: makeId("edge"), source: prompt.id, target: presetNode.id }, { id: makeId("edge"), source: reference.id, target: presetNode.id }, ] ); setViewport(TOOLBOX_TEMPLATE_VIEWPORT); setSelectedNodeId(group.id); setSelectedEdgeId(null); setActivePanel(null); setConnectionMenu(null); }, [addNodes] ); const addConfiguredTool = useCallback((tool: CapturedTool, index = 0) => { const node = createToolNode(tool, 360 + (index % 4) * 36, 300 + (index % 4) * 24); addNodes([node], []); setInspectorNodeId(node.id); setActivePanel(null); setConnectionMenu(null); toast.success(`已添加「${getToolLabel(tool)}」工具`); }, [addNodes]); const runConfiguredToolNode = useCallback( async (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); const tool = node?.tool; if (!node || !tool) { toast.error("当前节点不是可执行工具"); return; } const functionKey = getDraftString(node, "functionKey") || getToolFunctionKey(tool); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); if (!functionKey || !definition?.key) { toast.error("未找到该工具的功能配置"); return; } if (!videoCanvasFunctionConfig.enabled || !definition.enabled) { toast.error("视频画布功能未启用,请先在配置中开启"); return; } const route = getDraftString(node, "route") || getToolRoute(tool) || definition.route; const promptInput = getDraftString(node, "prompt") || definition.prompt || node.title; const modelOverride = getDraftString(node, "specifiedModel") || getDraftString(node, "model"); 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 uniqueUrls = (items: string[]) => Array.from(new Set(items.map((item) => item.trim()).filter(Boolean))).slice(0, 12); const imageReferenceLists = await Promise.all( incomingNodes .filter((item) => item.inputType === "image") .map((item) => resolveNodeReferenceImages(item)) ); const imageUrls = uniqueUrls(imageReferenceLists.flat()); const videoUrls = uniqueUrls( incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeReferenceMediaUrl(item)) ); const audioUrls = uniqueUrls( incomingNodes .filter((item) => item.inputType === "audio") .map((item) => getNodeReferenceMediaUrl(item)) ); const needsMedia = requiresMedia(tool) || functionRequiresMedia(definition); if (needsMedia && imageUrls.length === 0 && videoUrls.length === 0 && audioUrls.length === 0) { toast.error(`请先为「${node.title}」连接图片、视频或音频素材`); return; } if (route === "local") { toast.info(`${definition.label || node.title}属于本地画布操作,请在对应图片工具栏中使用`); return; } if (route === "angle-edit") { toast.info("多角度编辑器需要先选中图片素材,再从图片工具栏打开"); return; } if (route === "canvas-workflow-analyze" || definition.kind === "multimodal") { if (functionKey === "video-analysis" && videoUrls.length === 0) { toast.error("视频解析需要先连接视频素材"); return; } if (functionKey === "image-to-prompt" && imageUrls.length === 0) { toast.error("图片反推提示词需要先连接图片素材"); return; } const finalPrompt = composeFunctionPrompt(definition, promptInput); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "text", subtitle: "生成中", width: Math.max(item.width, 260), height: Math.max(item.height, 180), draftValues: { ...(item.draftValues || {}), generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", params: { ...getNodeParams(item), functionKey, imageUrls, prompt: finalPrompt, route, videoUrls, }, prompt: finalPrompt, }, } : item ) ); try { const response = await workflowAnalysisService.analyze({ imageUrls, mode: functionKey === "story-script" ? "story-script" : "video-analysis", nodeTitle: node.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, modelOverride), prompt: finalPrompt, videoUrls, }); if (!response.success) throw new Error(response.error || `${definition.label}生成失败`); const content = response.data?.raw || response.data?.summary || response.data?.agentPlan?.join("\n") || response.data?.analysisRows?.map((row, index) => { const scene = row.sceneDescription || `片段 ${index + 1}`; return [scene, row.imagePrompt, row.motionPrompt].filter(Boolean).join("\n"); }).join("\n\n") || ""; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "text", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), content: content ? [content] : item.draftValues?.content, generated: true, generationProgress: 100, generationStatus: "complete", prompt: finalPrompt, rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, }, } : item ) ); toast.success(`${definition.label || node.title}已生成`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : `${definition.label}生成失败`, generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); } return; } if (route === "canvas-generate-video" || definition.kind === "video") { if (functionKey === "audio-to-video" && imageUrls.length === 0) { toast.error("音频生视频当前需要同时连接一张参考图"); return; } const requestedMode = normalizeVideoGenerationMode(getDraftString(node, "mode")) || (functionKey === "text-to-video" ? "text-to-video" : functionKey === "first-last-frame-video" ? "first-last-frame" : imageUrls.length >= 2 ? "first-last-frame" : imageUrls.length >= 1 ? "first-frame" : "text-to-video"); 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; } const finalPrompt = composeFunctionPrompt(definition, promptInput); const previewUrl = referenceImages[0] || ""; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", inputType: "video", subtitle: "生成中", width: Math.max(item.width, 560), height: Math.max(item.height, 315), draftValues: { ...(item.draftValues || {}), action: "VIDEO_GENERATE", generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", params: { ...getNodeParams(item), functionKey, mode: requestedMode, prompt: finalPrompt, referenceImages, route, }, poster: previewUrl, prompt: finalPrompt, url: [], }, } : item ) ); try { const response = await videoGenerationService.generateVideo({ images: referenceImages, mode: requestedMode, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, modelOverride), prompt: finalPrompt, 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", inputType: "video", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: response.data?.url, nodeType: "video", prompt: finalPrompt, url: [response.data?.url], videoMeta: response.data?.metadata || null, }, } : item ) ); toast.success(`${definition.label || node.title}已生成`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : `${definition.label}生成失败`, generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); } return; } const sourceUrl = imageUrls[0] || getNodeReferenceMediaUrl(node); if (!sourceUrl) { toast.error(`${definition.label || node.title}需要先连接图片素材`); return; } const extraReferenceImages = imageUrls.filter((item) => item !== sourceUrl); const isWideGeneration = isWideImageGenerationFunction(functionKey); const gridLayout = getPresetGridLayout(functionKey); const finalPrompt = functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? composeMultiViewNineGridPrompt(definition, promptInput) : isOfficialImagePresetFunctionKey(functionKey) ? composeConfiguredImagePresetPrompt(definition, promptInput) : composeFunctionPrompt(definition, promptInput); const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : Math.max(node.width, 420); const resultHeight = isWideGeneration ? OFFICIAL_NINE_GRID_HEIGHT : Math.max(node.height, 280); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", inputType: "image", subtitle: "生成中", width: resultWidth, height: resultHeight, draftValues: { ...(item.draftValues || {}), action: isPresetGridFunction(functionKey) ? "IMAGE_GENERATE_NINE_GRID" : "IMAGE_GENERATE", functionKey, generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", gridColumns: gridLayout?.columns, gridPrompt: gridLayout ? finalPrompt : undefined, gridRows: gridLayout?.rows, mediaType: "image", mediaUrl: "", nodeType: "image", outputSizeLabel: isWideGeneration ? "2752 × 1536" : item.draftValues?.outputSizeLabel, params: { ...getNodeParams(item), functionKey, prompt: finalPrompt, referenceImages: [sourceUrl, ...extraReferenceImages], route, sourceUrl, }, pendingLabel: "生成中", prompt: finalPrompt, sourceUrl, url: [], }, } : item ) ); try { let generatedUrl = ""; if (route === "enhance") { const response = await imageGenerationService.enhanceImage(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "高清处理失败"); } else if (route === "extend") { const response = await imageGenerationService.extendImage(sourceUrl, { prompt: finalPrompt }); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "扩图处理失败"); } else if (route === "remove-bg") { const response = await imageGenerationService.removeBackground(sourceUrl); generatedUrl = response.success ? response.data?.url || "" : ""; if (!generatedUrl && !response.success) throw new Error(response.error || "抠图处理失败"); } else if (route === "canvas-generate-image" || definition.kind === "image-edit" || definition.kind === "utility") { const response = await imageGenerationService.generateImage({ aspectRatio: isWideGeneration ? "16:9" : undefined, billingFeature: getImageGenerationBillingFeature(functionKey), imageCount: 1, model: getFunctionModel(videoCanvasFunctionConfig, definition, modelOverride), prompt: finalPrompt, promptExtend: !isWideGeneration, referenceImage: sourceUrl, referenceImages: extraReferenceImages, }); generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (!generatedUrl && !response.success) throw new Error(response.error || `${definition.label}生成失败`); } else { throw new Error(`${definition.label || node.title}暂未配置可执行路由`); } if (!generatedUrl) throw new Error(`${definition.label || node.title}未返回生成结果`); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", inputType: "image", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "image", mediaUrl: generatedUrl, nodeType: "image", nineGridCells: gridLayout ? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index)) : item.draftValues?.nineGridCells, nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite, prompt: finalPrompt, sourceUrl, url: [generatedUrl], }, } : item ) ); toast.success(`${definition.label || node.title}已生成`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : `${definition.label}生成失败`, generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); } }, [edges, nodes, videoCanvasFunctionConfig] ); const addReferenceModulesForTool = useCallback( (nodeId: string) => { const anchor = nodes.find((item) => item.id === nodeId); if (!anchor?.tool) { toast.error("当前节点不是可添加素材的工具"); return; } const functionKey = getDraftString(anchor, "functionKey") || getToolFunctionKey(anchor.tool); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); if (!functionKey || !definition?.key) { toast.error("未找到该工具的功能配置"); return; } const templates = getToolReferenceModuleTemplates(anchor.tool, definition); if (templates.length === 0) { toast.info(`${definition.label || anchor.title}不需要额外素材`); return; } const specs = templates.map((template, index) => getToolReferenceMediaSpec(template, definition, index) ); const gap = OFFICIAL_NODE_GAP; const totalHeight = specs.reduce((sum, spec) => sum + spec.height, 0) + Math.max(0, specs.length - 1) * 18; let cursorY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2); const nextNodes = specs.map((spec) => { const node = createMediaResourceNode({ action: spec.action, height: spec.height, mediaType: spec.mediaType, mediaUrl: spec.mediaUrl, title: spec.title, width: spec.width, x: Math.round(anchor.x - spec.width - gap), y: cursorY, }); const positionedNode = { ...node, x: Math.round(anchor.x - spec.width - gap), y: cursorY, }; cursorY += spec.height + 18; return positionedNode; }); const nextEdges = nextNodes.map((node) => ({ id: makeId("edge"), source: node.id, target: anchor.id, })); addNodes(nextNodes, nextEdges); setSelectedNodeId(nextNodes[0]?.id || anchor.id); setInspectorNodeId(null); setActivePanel(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); toast.success( nextNodes.length === 1 ? `已为「${anchor.title}」添加素材节点` : `已为「${anchor.title}」添加 ${nextNodes.length} 个素材节点` ); }, [addNodes, nodes, videoCanvasFunctionConfig] ); 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 compactSpawn = window.innerWidth < 900 || window.innerHeight < 640; const useViewportTopLeftSpawn = kind === "text" || kind === "image" || kind === "video" || kind === "video-clip" || kind === "director" || kind === "audio" || kind === "script" || kind === "legacy-script"; const offset = compactSpawn || useViewportTopLeftSpawn ? { x: 0, y: 0 } : TARGET_MODULE_CENTER_OFFSETS[kind]; const sameKindCount = nodes.filter((node) => getTargetModuleKind(node) === kind).length; const spawnOffset = useViewportTopLeftSpawn ? 0 : sameKindCount * 28; const center = point || { x: viewportCenter.x + offset.x + spawnOffset, y: viewportCenter.y + offset.y + spawnOffset, }; const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); const useTopLeftSpawn = useViewportTopLeftSpawn && !point; const node = { ...created, x: Math.round(useTopLeftSpawn ? center.x : center.x - created.width / 2), y: Math.round(useTopLeftSpawn ? center.y : center.y - created.height / 2), }; addNodes([node], []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); }, [addNodes, nodes, 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 addAssetFromLibrary = useCallback( (asset: CanvasLibraryAsset) => { const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const width = asset.mediaType === "video" ? 560 : asset.mediaType === "audio" ? 430 : 420; const height = asset.mediaType === "video" ? 315 : asset.mediaType === "audio" ? 170 : 280; const node = createMediaResourceNode({ action: `${asset.mediaType.toUpperCase()}_RESOURCE`, height, mediaType: asset.mediaType, mediaUrl: asset.url, title: asset.label, width, x: Math.round(center.x - width / 2), y: Math.round(center.y - height / 2), }); addNodes([node], []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success(`已添加「${asset.label}」素材`); }, [addNodes, toCanvasPoint] ); const addStyleAssetFromMarket = useCallback( (card: AssetMarketCard) => { const [title, author, count, model] = card; const sameKindCount = nodes.filter((node) => node.kind === "material-style").length; const anchor = toCanvasPoint(MATERIAL_STYLE_SCREEN_ANCHOR.x, MATERIAL_STYLE_SCREEN_ANCHOR.y); const node = createMaterialStyleNode({ author, count, model, thumbnailUrl: STYLE_MARKET_THUMBNAIL_URL, title, x: anchor.x + sameKindCount * 28, y: anchor.y + sameKindCount * 28, }); addNodes([node], []); setSelectedNodeId(null); setSelectedEdgeId(null); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); }, [addNodes, nodes, toCanvasPoint] ); const applyVideoEffectFromMarket = useCallback( (preset: VideoCanvasEffectPreset) => { if (!effectLibraryTargetNodeId) { setActivePanel(null); return; } const connectedEffectEdge = edges.find((edge) => { if (edge.target !== effectLibraryTargetNodeId) return false; return isVideoEffectMaterialNode( nodes.find((node) => node.id === edge.source), ); }); const connectedEffectNode = connectedEffectEdge ? nodes.find((node) => node.id === connectedEffectEdge.source) : null; if (connectedEffectNode) { setNodes((current) => current.map((node) => { if (node.id !== connectedEffectNode.id) return node; const replacement = createMaterialEffectNode({ preset, x: node.x, y: node.y, }); return { ...replacement, id: node.id, }; }), ); } else { const sameKindCount = nodes.filter(isVideoEffectMaterialNode).length; const anchor = toCanvasPoint( MATERIAL_STYLE_SCREEN_ANCHOR.x, MATERIAL_STYLE_SCREEN_ANCHOR.y, ); const effectNode = createMaterialEffectNode({ preset, x: Math.round(anchor.x + sameKindCount * 28), y: Math.round(anchor.y + sameKindCount * 28), }); setNodes((current) => [...current, effectNode]); setEdges((current) => [ ...current, { id: makeId("edge"), source: effectNode.id, target: effectLibraryTargetNodeId, }, ]); } setSelectedNodeId(effectLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); setEffectLibraryTargetNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); }, [edges, effectLibraryTargetNodeId, nodes, toCanvasPoint], ); const applyVideoRolesFromLibrary = useCallback( (selectedRoleIds: string[]) => { if (!roleLibraryTargetNodeId) { setActivePanel(null); return; } const selectedPresets = videoCanvasFunctionConfig.rolePresets .filter((preset) => selectedRoleIds.includes(preset.id)) .slice(0, 4); const connectedRoleEdges = edges.filter((edge) => { if (edge.target !== roleLibraryTargetNodeId) return false; return isVideoRoleMaterialNode( nodes.find((node) => node.id === edge.source), ); }); const connectedRoleNodes = connectedRoleEdges .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => Boolean(node)); const availableByRoleId = new Map( nodes .filter(isVideoRoleMaterialNode) .map((node) => [getDraftString(node, "roleId"), node]), ); const connectedByRoleId = new Map( connectedRoleNodes.map((node) => [getDraftString(node, "roleId"), node]), ); for (const [roleId, node] of connectedByRoleId) { availableByRoleId.set(roleId, node); } const sameKindCount = nodes.filter(isVideoRoleMaterialNode).length; const anchor = toCanvasPoint( MATERIAL_STYLE_SCREEN_ANCHOR.x + MATERIAL_STYLE_NODE_SIZE + 28, MATERIAL_STYLE_SCREEN_ANCHOR.y, ); const newRoleNodes = selectedPresets .filter((preset) => !availableByRoleId.has(preset.id)) .map((preset, index) => createMaterialRoleNode({ preset, x: Math.round(anchor.x + (sameKindCount + index) * 28), y: Math.round(anchor.y + (sameKindCount + index) * 28), }), ); const newRoleByRoleId = new Map( newRoleNodes.map((node) => [getDraftString(node, "roleId"), node]), ); const selectedRoleNodes = selectedPresets .map( (preset) => availableByRoleId.get(preset.id) || newRoleByRoleId.get(preset.id), ) .filter((node): node is CloneNode => Boolean(node)); const selectedRoleNodeIds = new Set(selectedRoleNodes.map((node) => node.id)); const connectedRoleNodeIds = new Set( connectedRoleEdges.map((edge) => edge.source), ); setNodes((current) => [ ...current.map((node) => { if (!selectedRoleNodeIds.has(node.id)) return node; const roleId = getDraftString(node, "roleId"); const preset = selectedPresets.find((item) => item.id === roleId); if (!preset) return node; const replacement = createMaterialRoleNode({ preset, x: node.x, y: node.y, }); return { ...replacement, id: node.id }; }), ...newRoleNodes, ]); setEdges((current) => [ ...current.filter((edge) => { if (edge.target !== roleLibraryTargetNodeId) return true; const sourceNode = nodes.find((node) => node.id === edge.source); if (!isVideoRoleMaterialNode(sourceNode)) return true; return selectedRoleNodeIds.has(edge.source); }), ...selectedRoleNodes .filter((node) => !connectedRoleNodeIds.has(node.id)) .map((node) => ({ id: makeId("edge"), source: node.id, target: roleLibraryTargetNodeId, })), ]); setSelectedNodeId(roleLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); setRoleLibraryTargetNodeId(null); setVideoRoleAgreementOpen(false); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); }, [ edges, nodes, roleLibraryTargetNodeId, toCanvasPoint, videoCanvasFunctionConfig.rolePresets, ], ); const addRoleAssetFromLibrary = useCallback( (role: RoleLibraryItem) => { const roleNodes = ROLE_LIBRARY_NODE_LAYOUT.map((layout) => { const created = createMediaResourceNode({ action: "ROLE_LIBRARY_RESOURCE", height: layout.height, mediaType: "image", mediaUrl: role.images[layout.imageKey], prompt: role.prompt, title: `${role.name} ${layout.title}`, width: layout.width, x: layout.x, y: layout.y, }); return { ...created, subtitle: "角色库资产", draftValues: { ...(created.draftValues || {}), roleDescription: role.description, roleImageKind: layout.title, roleTags: role.tags, source: "role-library", }, }; }); addNodes(roleNodes, []); setViewport(ROLE_LIBRARY_APPLY_VIEWPORT); setSelectedNodeId(null); setSelectedEdgeId(null); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success("已应用到画布"); }, [addNodes] ); const reuseHistoryAssetNode = useCallback( (sourceNode: CloneNode) => { const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const node: CloneNode = { ...sourceNode, id: makeId("history"), x: Math.round(center.x - sourceNode.width / 2), y: Math.round(center.y - sourceNode.height / 2), draftValues: { ...(sourceNode.draftValues || {}), historySourceNodeId: sourceNode.id, source: "history-assets", }, }; addNodes([node], []); setSelectedNodeId(node.id); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success("已应用到画布"); }, [addNodes, toCanvasPoint] ); const addHistoryPickerNodes = useCallback( (sourceNodes: CloneNode[]) => { const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const createdNodes = sourceNodes.flatMap((sourceNode, index) => { const mediaType = sourceNode.inputType === "video" || sourceNode.inputType === "audio" ? sourceNode.inputType : "image"; const mediaUrl = getNodeMediaUrl(sourceNode) || getNodeReferenceMediaUrl(sourceNode); if (!mediaUrl) return []; const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; const sameTypeCount = nodes.filter((node) => node.inputType === mediaType).length; const width = mediaType === "video" ? 622 : 350; const height = mediaType === "audio" ? 148 : 350; return createMediaResourceNode({ height, mediaType, mediaUrl, title: `${label}节点 ${sameTypeCount + index + 1}`, width, x: Math.round(center.x + index * 28), y: Math.round(center.y + index * 28), }); }); if (createdNodes.length === 0) return; addNodes(createdNodes, []); setSelectedNodeId(null); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); }, [addNodes, nodes, 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); setSelectedEdgeId(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); setSelectedEdgeId(null); 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(); if (suppressConnectionClickRef.current) { suppressConnectionClickRef.current = false; return; } const canvasPoint = { x: (event.clientX - viewport.x) / viewport.zoom, y: (event.clientY - viewport.y) / viewport.zoom, }; setSelectedNodeId(node.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setInspectorNodeId(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState({ cursorCanvasX: canvasPoint.x, cursorCanvasY: canvasPoint.y, sourceId: side === "source" ? node.id : null, targetId: side === "target" ? node.id : null, }); }, [viewport.x, viewport.y, viewport.zoom]); 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 connectExistingNodes = useCallback( (sourceId: string, targetId: string, options: { notify?: boolean } = {}) => { const shouldNotify = options.notify !== false; if (sourceId === targetId) { toast.info("不能连接到同一个节点"); return false; } const source = nodes.find((node) => node.id === sourceId); const target = nodes.find((node) => node.id === targetId); if (!source || !target) { toast.error("未找到可连接的节点"); return false; } const exists = edges.some((edge) => edge.source === sourceId && edge.target === targetId); if (exists) { toast.info("这两个节点已经连接"); return false; } setEdges((current) => [...current, { id: makeId("edge"), source: sourceId, target: targetId }]); setSelectedNodeId(targetId); setSelectedEdgeId(null); if (shouldNotify) { toast.success(`已连接「${source.title}」到「${target.title}」`); } return true; }, [edges, 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 "打开导演台": { setDirectorConsoleNodeId(nodeId); setSelectedNodeId(nodeId); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); return; } 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 "参考": case "标记": case "风格": case "自己编写分镜脚本": { setSelectedNodeId(nodeId); setNodeContextMenu(null); setConnectionMenu(null); setCanvasCommand(null); return; } 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 !== "自己编写内容" && actionLabel !== "首帧生成视频" && 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 < LOCAL_EDIT_MAX_POLL_ATTEMPTS; 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 localEditDisplayPrompt = response.data.displayPrompt || response.data.prompt || "局部编辑处理"; 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: localEditDisplayPrompt, 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: localEditDisplayPrompt, regions: validRegions, sourceNodeId: anchor.id, sourceHeight: response.data.sourceHeight, sourceUrl, sourceWidth: response.data.sourceWidth, taskId: response.data.taskId, }, pendingLabel: "局部编辑中", prompt: localEditDisplayPrompt, 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 || "生成角色三视图", ); 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 = placeholderNode; const resultNode: CloneNode = { ...created, title: resultTitle, y: isWideGeneration ? placeholderNode.y : Math.round(anchor.y + anchor.height / 2 - created.height / 2), draftValues: { ...(created.draftValues || {}), action: isPresetGridFunction(functionKey) ? "IMAGE_GENERATE_NINE_GRID" : "IMAGE_GENERATE", functionKey, generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", 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, }, pendingLabel: "生成中", 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("已提交图片生成"); try { if (!videoCanvasFunctionConfig.enabled || !definition?.enabled) { throw new Error("视频画布功能未启用,请先在配置中开启"); } 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, }); const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; if (!generatedUrl) { throw new Error(response.error || `${definition.label}生成失败`); } setNodes((current) => current.map((item) => item.id === resultNode.id ? { ...item, kind: "media", inputType: "image", subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), action: "IMAGE_RESOURCE", generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "image", mediaUrl: generatedUrl, nodeType: "image", nineGridCells: gridLayout ? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index)) : item.draftValues?.nineGridCells, nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite, prompt: finalPrompt, sourceUrl, url: [generatedUrl], }, } : item ) ); toast.success("已按当前提示词生成图片结果"); } catch (error) { setNodes((current) => current.map((item) => item.id === resultNode.id ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : `${definition.label}生成失败`, generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : `${definition.label}生成失败`); } }, [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 effectMaterials = incomingNodes.filter(isVideoEffectMaterialNode); const effectDescriptors = effectMaterials.map((item) => ({ id: getDraftString(item, "effectId"), title: getDraftString(item, "effectTitle") || item.title.replace(/^素材-特效-/, ""), prompt: getDraftString(item, "effectPrompt"), model: getDraftString(item, "model"), thumbnailUrl: getDraftString(item, "thumbnailUrl") || getNodeMediaUrl(item), })); const roleMaterials = incomingNodes.filter(isVideoRoleMaterialNode); const roleDescriptors = roleMaterials.map((item) => ({ id: getDraftString(item, "roleId"), assetId: getDraftString(item, "assetId"), name: getDraftString(item, "roleName") || item.title.replace(/^素材-角色-/, ""), prompt: getDraftString(item, "rolePrompt"), tags: getDraftString(item, "roleTags"), thumbnailUrl: getDraftString(item, "thumbnailUrl") || getNodeMediaUrl(item), })); const imageUrls = Array.from( new Set( [ ...imageReferenceLists.flat(), ...getVideoCanvasMagicMarks(node).map((mark) => mark.imageUrl), ] .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 magicMarks = payload?.magicMarks || getVideoCanvasMagicMarks(node); 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 markedImageUrls = Array.from( new Set(magicMarks.map((mark) => mark.imageUrl).filter(Boolean)) ).slice(0, 4); const referenceImages = requestedMode === "text-to-video" && markedImageUrls.length > 0 ? markedImageUrls : 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 markLabels = magicMarks.map( (mark) => mark.labelOptions[mark.selectedIndex] || mark.labelOptions[0] ); const promptWithMarks = [ rawPrompt, markLabels.length > 0 ? `重点遵循已标记元素:${markLabels.map((label) => `@${label}`).join("、")}` : "", ] .filter(Boolean) .join(" "); const promptWithEffects = [ promptWithMarks, ...effectDescriptors.map((effect) => [ `应用视频特效「${effect.title}」。`, effect.prompt, ] .filter(Boolean) .join(" "), ), ] .filter(Boolean) .join(" "); const promptWithRoles = [ promptWithEffects, ...roleDescriptors.map((role) => [ `角色引用「${role.name}」${role.assetId ? `(合规资产 ${role.assetId})` : ""}。`, role.prompt, ] .filter(Boolean) .join(" "), ), ] .filter(Boolean) .join(" "); const finalPrompt = composeFunctionPrompt( definition, promptWithRoles || node.title ); const previewUrl = referenceImages[0] || getNodeReferenceMediaUrl(incomingNodes.find((item) => item.inputType === "image")) || getNodeReferenceMediaUrl(node) || ""; const savedSettings: ComposerSettings & { duration: string } = { autoValidateMaterial: requestedSettings?.autoValidateMaterial ?? normalizeComposerBoolean( paramsSettings.autoValidateMaterial ?? paramsSettings.auto_validate_material, true, ), audio: requestedSettings?.audio || String(paramsSettings.audio || "开启"), camera: requestedSettings?.camera || String(paramsSettings.camera || "摄像机"), count: requestedSettings?.count || String(paramsSettings.count || "1个"), model: normalizeModelRef(preferredModelOverride) || "auto", modelLabel: requestedSettings?.modelLabel || String(paramsSettings.modelLabel || ""), panorama: requestedSettings?.panorama || String(paramsSettings.panorama || "全能参考"), ratio: formatVideoRatioLabel(ratio), resolution: formatVideoResolutionLabel(resolution), searchEnabled: requestedSettings?.searchEnabled ?? normalizeComposerBoolean( paramsSettings.searchEnabled ?? paramsSettings.search_enabled ?? paramsSettings.searchable, true, ), 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, effects: effectDescriptors, roles: roleDescriptors, mode: requestedMode, magicMarks, 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, effects: effectDescriptors, roles: roleDescriptors, mode: requestedMode, magicMarks, 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 runStoryScriptToolbarAction = useCallback( (nodeId: string, action: StoryToolbarAction) => { const anchor = nodes.find((item) => item.id === nodeId); if (!anchor) return; const rowKey = (row: StoryScriptRow) => row.shot || row.id; const closeTransientUi = () => { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); }; if (action === "regenerate") { 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); 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 definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script"); const currentParams = getNodeParams(anchor); const selectedModelRef = normalizeModelRef(currentParams.model); const draftPrompt = getDraftString(anchor, "prompt") || STORY_SCRIPT_PROMPT; const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim()); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成中", draftValues: { ...(item.draftValues || {}), generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", params: { ...getNodeParams(item), functionKey: "story-script", imageUrls, model: selectedModelRef || undefined, prompt, route: "canvas-workflow-analyze", videoUrls, }, prompt, }, } : item ) ); setSelectedNodeId(nodeId); closeTransientUi(); void (async () => { try { const response = await workflowAnalysisService.analyze({ imageUrls, mode: "story-script", nodeTitle: anchor.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, selectedModelRef), prompt, 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, subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), content: content ? [content] : item.draftValues?.content, generated: true, generationProgress: 100, generationStatus: "complete", prompt, rows: response.data?.analysisRows || response.data?.shotIdeas || item.draftValues?.rows, }, } : item ) ); toast.success("故事脚本已重新生成"); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : "故事脚本生成失败", generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : "故事脚本生成失败"); } })(); return; } const rows = getStoryScriptRows(anchor); if (rows.length === 0) { toast.error("当前脚本没有可操作的镜头行"); return; } const existingStoryboards = nodes.filter( (item) => String(item.draftValues?.sourceScriptNodeId || "") === nodeId && String(item.draftValues?.storyToolNodeType || "") === "storyboard" ); const existingStoryboardKeys = new Set( existingStoryboards .map((item) => { const storyRow = item.draftValues?.storyRow as StoryScriptRow | undefined; return storyRow ? rowKey(storyRow) : ""; }) .filter(Boolean) ); const rowsToCreate = rows.filter((row) => !existingStoryboardKeys.has(rowKey(row))); const sceneNodes = nodes.filter( (item) => String(item.draftValues?.sourceScriptNodeId || "") === nodeId && String(item.draftValues?.storyToolNodeType || "") === "scene" ); const sceneByKey = new Map( sceneNodes .map((item) => { const storyRow = item.draftValues?.storyRow as StoryScriptRow | undefined; return storyRow ? [rowKey(storyRow), item] as const : null; }) .filter((item): item is readonly [string, CloneNode] => Boolean(item)) ); const storyboardByKey = new Map( existingStoryboards .map((item) => { const storyRow = item.draftValues?.storyRow as StoryScriptRow | undefined; return storyRow ? [rowKey(storyRow), item] as const : null; }) .filter((item): item is readonly [string, CloneNode] => Boolean(item)) ); if (action === "create-storyboard") { if (rowsToCreate.length === 0) { setSelectedNodeId(existingStoryboards[0]?.id || nodeId); closeTransientUi(); toast.info("当前脚本已经创建过分镜节点"); return; } const width = 420; const height = 280; const fallbackX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP + (sceneNodes.length > 0 ? 472 : 0)); const nextNodes = rowsToCreate.map((row) => { const rowIndex = Math.max(0, rows.findIndex((item) => rowKey(item) === rowKey(row))); const sceneNode = sceneByKey.get(rowKey(row)); const x = sceneNode ? Math.round(sceneNode.x + sceneNode.width + 52) : fallbackX; const y = sceneNode ? sceneNode.y : Math.round(anchor.y + rowIndex * (height + 30)); const prompt = [ row.imagePrompt, `景别:${row.shotSize}`, `光影氛围:${row.lighting}`, row.dialogue && row.dialogue !== "+" ? `对白/旁白:${row.dialogue}` : "", row.voiceAndSfx && row.voiceAndSfx !== "无" ? `音效:${row.voiceAndSfx}` : "", `运镜:${row.cameraMovement}`, row.motionPrompt, ].filter(Boolean).join("\n"); return { id: makeId("storyboard"), kind: "media", title: `分镜 ${row.shot}`, subtitle: row.scene, inputType: "image", x, y, width, height, accent: "#8790ff", draftValues: { action: "IMAGE_GENERATE", functionKey: "image-repaint", generated: false, mediaType: "image", mediaUrl: row.keyframeUrl, nodeType: "image", params: { functionKey: "image-repaint", motionPrompt: row.motionPrompt, prompt, sceneDescription: row.sceneDescription, sourceSceneNodeId: sceneNode?.id, sourceScriptNodeId: nodeId, storyRow: row, }, prompt, sourceScriptNodeId: nodeId, sourceUrl: row.keyframeUrl, storyRow: row, storyToolNodeType: "storyboard", url: row.keyframeUrl ? [row.keyframeUrl] : [], }, } satisfies CloneNode; }); const nextEdges = nextNodes.map((node) => ({ id: makeId("edge"), source: String(node.draftValues?.params && (node.draftValues.params as Record).sourceSceneNodeId || anchor.id), target: node.id, })); addNodes(nextNodes, nextEdges); setSelectedNodeId(nextNodes[0]?.id || anchor.id); closeTransientUi(); toast.success(`已批量生成 ${nextNodes.length} 个分镜图片节点`); return; } const existingVideos = nodes.filter( (item) => String(item.draftValues?.sourceScriptNodeId || "") === nodeId && String(item.draftValues?.storyToolNodeType || "") === "story-video" ); const existingVideoKeys = new Set( existingVideos .map((item) => { const storyRow = item.draftValues?.storyRow as StoryScriptRow | undefined; return storyRow ? rowKey(storyRow) : ""; }) .filter(Boolean) ); const rowsForVideo = rows.filter((row) => !existingVideoKeys.has(rowKey(row))); if (rowsForVideo.length === 0) { setSelectedNodeId(existingVideos[0]?.id || nodeId); closeTransientUi(); toast.info("当前脚本已经创建过视频生成器组"); return; } const width = 622; const height = 350; const fallbackX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP + (existingStoryboards.length > 0 ? 472 : 0)); const nextNodes = rowsForVideo.map((row) => { const rowIndex = Math.max(0, rows.findIndex((item) => rowKey(item) === rowKey(row))); const storyboardNode = storyboardByKey.get(rowKey(row)); const referenceImage = storyboardNode ? getNodeReferenceMediaUrl(storyboardNode) : row.keyframeUrl; const previewUrl = referenceImage || row.keyframeUrl || FIRST_FRAME_IMAGE_URL; const duration = 5; const settings: ComposerSettings = { audio: "开启", camera: "摄像机", count: "1个", duration: `${duration}s`, model: "seedance-2.0-vip", modelLabel: "Seedance 2.0 VIP", panorama: referenceImage ? "首帧生成视频" : "文生视频", ratio: "16:9", resolution: "720P", }; const prompt = buildStoryVideoPrompt(row); return { id: makeId("story-video"), kind: "media", title: `分镜视频-#${rowIndex + 1}`, subtitle: `${row.scene} · Seedance 2.0 VIP`, inputType: "video", x: storyboardNode ? Math.round(storyboardNode.x + storyboardNode.width + 52) : fallbackX, y: storyboardNode ? storyboardNode.y : Math.round(anchor.y + rowIndex * (height + 30)), width, height, accent: "#8b96ff", draftValues: { action: "VIDEO_GENERATE", functionKey: "first-frame-video", generated: false, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", outputSizeLabel: `720P · ${duration}s`, params: { duration, functionKey: "first-frame-video", imagePrompt: row.imagePrompt, mode: referenceImage ? "first-frame" : "text-to-video", model: "seedance-2.0-vip", modelLabel: "Seedance 2.0 VIP", motionPrompt: row.motionPrompt, prompt, ratio: "16:9", referenceImages: referenceImage ? [referenceImage] : [], resolution: "720p", sceneDescription: row.sceneDescription, settings, sourceShotDuration: row.duration, sourceScriptNodeId: nodeId, sourceStoryboardNodeId: storyboardNode?.id, storyRow: row, }, poster: previewUrl, prompt, sourceScriptNodeId: nodeId, sourceStoryboardNodeId: storyboardNode?.id, sourceUrl: previewUrl, storyRow: row, storyToolNodeType: "story-video", url: [], }, } satisfies CloneNode; }); const nextEdges = nextNodes.map((node) => ({ id: makeId("edge"), source: String(node.draftValues?.sourceStoryboardNodeId || anchor.id), target: node.id, })); addNodes(nextNodes, nextEdges); setSelectedNodeId(nextNodes[0]?.id || anchor.id); closeTransientUi(); toast.success(`已创建 ${nextNodes.length} 个视频生成器组`); }, [addNodes, 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 downloadMediaNode = useCallback((node: CloneNode) => { const mediaUrl = getNodeMediaUrl(node) || getNodeReferenceMediaUrl(node); if (!mediaUrl) { toast.error("当前资产没有可下载地址"); return; } const link = document.createElement("a"); link.href = mediaUrl; const extension = node.inputType === "video" ? "mp4" : node.inputType === "audio" ? "mp3" : "png"; link.download = `${node.title || "未命名资产"}.${extension}`; link.rel = "noreferrer"; document.body.appendChild(link); link.click(); link.remove(); toast.success("已开始下载当前资产"); }, []); const downloadVideoNode = useCallback((node: CloneNode) => { downloadMediaNode(node); }, [downloadMediaNode]); const openHistoryWatermarkRules = useCallback((node: CloneNode) => { setHistoryListDownloadPendingId(node.id); setHistoryWatermarkRulesOpen(true); }, []); const saveHistoryWatermarkRules = useCallback(() => { setHistoryWatermarkRulesOpen(false); setHistoryListDownloadPendingId(null); }, []); const extractVideoFrameNode = useCallback( (nodeId: string, kind: VideoFrameExtractionKind) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode) return; const frameLabelMap: Record = { current: "当前帧", first: "首帧", last: "尾帧", }; const frameUrl = getVideoNodePreviewUrl(videoNode) || getNodeSourceMediaUrl(videoNode) || getNodeMediaUrl(videoNode) || FIRST_FRAME_IMAGE_URL; const existingFrameCount = nodes.filter( (item) => item.draftValues?.sourceVideoNodeId === videoNode.id && item.draftValues?.frameExtractionKind === kind ).length; const title = `${videoNode.title || "视频"}-${frameLabelMap[kind]}`; const created = createMediaResourceNode({ action: "VIDEO_FRAME_EXTRACT", height: 236, mediaType: "image", mediaUrl: frameUrl, title, width: 420, x: videoNode.x + videoNode.width + OFFICIAL_NODE_GAP, y: videoNode.y + existingFrameCount * 260, }); const frameNode: CloneNode = { ...created, subtitle: "视频帧截取", draftValues: { ...(created.draftValues || {}), frameExtractionKind: kind, generated: true, outputSizeLabel: "1280 × 720", sourceVideoNodeId: videoNode.id, }, }; addNodes([frameNode], [{ id: makeId("edge"), source: videoNode.id, target: frameNode.id }]); setSelectedNodeId(frameNode.id); setActiveVideoToolbarMode(null); toast.success(`已截取${frameLabelMap[kind]}`); }, [addNodes, nodes] ); const createVideoHdUpscaleFromVideo = useCallback( (nodeId: string, option: string) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode) return; const resolution = normalizeVideoHdResolutionOption(option); const existingHdNode = nodes.find( (item) => getDraftString(item, "action") === "VIDEO_HD_UPSCALE" && getDraftString(item, "sourceVideoNodeId") === videoNode.id && getDraftString(item, "hdResolution") === resolution ); const hdNode = createVideoHdUpscaleNode({ existingNode: existingHdNode, option, sourceNode: videoNode, }); if (existingHdNode) { setNodes((current) => current.map((item) => (item.id === hdNode.id ? hdNode : item))); } else { addNodes([hdNode], [{ id: makeId("edge"), source: videoNode.id, target: hdNode.id }]); } setSelectedNodeId(hdNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); }, [addNodes, nodes] ); const generateVideoHdUpscaleNode = useCallback( (nodeId: string) => { const hdNode = nodes.find((item) => item.id === nodeId); if (!hdNode) return; const sourceNode = nodes.find((item) => item.id === getDraftString(hdNode, "sourceVideoNodeId")); const mediaUrl = getNodeReferenceMediaUrl(sourceNode) || getDraftString(hdNode, "sourceUrl") || getNodeMediaUrl(hdNode) || SCRIPT_VIDEO_REFERENCE_URL; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generated: false, generationProgress: 8, generationStatus: "pending", mediaUrl: "", url: [], }, } : item ) ); setSelectedNodeId(nodeId); window.setTimeout(() => { setNodes((current) => current.map((item) => item.id === nodeId && getDraftString(item, "generationStatus") === "pending" ? { ...item, subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl, url: [mediaUrl], }, } : item ) ); }, 1800); }, [nodes] ); const applyVideoCropSelection = useCallback( (nodeId: string) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode || videoCropGenerateStates[nodeId]?.status === "loading") return; const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode); if (!sourceUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } setVideoCropGenerateStates((current) => ({ ...current, [nodeId]: { progress: 37, status: "loading" }, })); toast.info("正在裁剪视频,请稍候…"); window.setTimeout(() => { setVideoCropGenerateStates((current) => current[nodeId]?.status === "loading" ? { ...current, [nodeId]: { progress: 68, status: "loading" } } : current ); }, 650); window.setTimeout(() => { const existingIndex = nodes.filter( (item) => getDraftString(item, "action") === "VIDEO_CROP" && getDraftString(item, "sourceVideoNodeId") === videoNode.id ).length; const cropNode = createVideoCropResultNode({ existingIndex, sourceNode: videoNode }); addNodes([cropNode], [{ id: makeId("edge"), source: videoNode.id, target: cropNode.id }]); setSelectedNodeId(cropNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); setVideoCropGenerateStates((current) => { const next = { ...current }; delete next[nodeId]; return next; }); toast.success("已创建裁剪视频节点并开始上传"); window.setTimeout(() => { setNodes((current) => current.map((item) => item.id === cropNode.id && getDraftString(item, "generationStatus") === "pending" ? { ...item, subtitle: "裁剪结果", draftValues: { ...(item.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: sourceUrl, uploadStatus: "complete", url: [sourceUrl], }, } : item ) ); }, 1800); }, 1300); }, [addNodes, nodes, videoCropGenerateStates] ); const commitSubtitleEraseRegions = useCallback( (nodeId: string, nextRegions: SubtitleEraseRegion[]) => { const previousRegions = subtitleEraseRegions[nodeId] || []; if (JSON.stringify(previousRegions) === JSON.stringify(nextRegions)) return; setSubtitleEraseRegions((current) => ({ ...current, [nodeId]: nextRegions })); setSubtitleEraseRegionHistory((current) => { const history = current[nodeId] || { future: [], past: [] }; return { ...current, [nodeId]: { future: [], past: [...history.past, previousRegions], }, }; }); }, [subtitleEraseRegions] ); const undoSubtitleEraseRegionChange = useCallback( (nodeId: string) => { const history = subtitleEraseRegionHistory[nodeId]; if (!history?.past.length) return; const currentRegions = subtitleEraseRegions[nodeId] || []; const nextRegions = history.past[history.past.length - 1] || []; setSubtitleEraseRegions((current) => ({ ...current, [nodeId]: nextRegions })); setSubtitleEraseRegionHistory((current) => { const currentHistory = current[nodeId] || { future: [], past: [] }; return { ...current, [nodeId]: { future: [currentRegions, ...currentHistory.future], past: currentHistory.past.slice(0, -1), }, }; }); }, [subtitleEraseRegionHistory, subtitleEraseRegions] ); const redoSubtitleEraseRegionChange = useCallback( (nodeId: string) => { const history = subtitleEraseRegionHistory[nodeId]; if (!history?.future.length) return; const currentRegions = subtitleEraseRegions[nodeId] || []; const nextRegions = history.future[0] || []; setSubtitleEraseRegions((current) => ({ ...current, [nodeId]: nextRegions })); setSubtitleEraseRegionHistory((current) => { const currentHistory = current[nodeId] || { future: [], past: [] }; return { ...current, [nodeId]: { future: currentHistory.future.slice(1), past: [...currentHistory.past, currentRegions], }, }; }); }, [subtitleEraseRegionHistory, subtitleEraseRegions] ); const applySubtitleEraseSelection = useCallback( (nodeId: string, mode: SubtitleEraseMode = subtitleEraseMode) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode) return; const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode); if (!sourceUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } const regions = subtitleEraseRegions[nodeId] || []; if (mode === "region" && !regions.length) { toast.warning("请先框选需要擦除的字幕区域"); return; } const existingIndex = nodes.filter( (item) => getDraftString(item, "action") === "VIDEO_SUBTITLE_ERASE" && getDraftString(item, "sourceVideoNodeId") === videoNode.id ).length; const eraseNode = createVideoSubtitleEraseNode({ existingIndex, mode, regions, sourceNode: videoNode }); setNodes((current) => [...current, eraseNode]); setEdges((current) => [...current, { id: makeId("edge"), source: videoNode.id, target: eraseNode.id }]); setSelectedNodeId(videoNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); window.setTimeout(() => { setNodes((current) => current.map((item) => item.id === eraseNode.id && getDraftString(item, "generationStatus") === "pending" ? { ...item, height: videoNode.height, subtitle: "去字幕结果", width: videoNode.width, draftValues: { ...(item.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: sourceUrl, url: [sourceUrl], }, } : item ) ); }, VIDEO_SUBTITLE_ERASE_SIMULATED_MS); }, [nodes, subtitleEraseMode, subtitleEraseRegions] ); const splitVideoAudioTrack = useCallback( (nodeId: string, mode: VideoAudioSplitMode) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode || videoAudioSplitStates[nodeId]?.status === "loading") return; const splitCopy: Record< VideoAudioSplitMode, { audioAction: string; audioSuffix: string; audioSubtitle: string; processingLabel: string; silentAction: string; } > = { "audio-video": { audioAction: "VIDEO_AUDIO_SPLIT", audioSuffix: "音轨", audioSubtitle: "音视频分离", processingLabel: "音视频分离", silentAction: "VIDEO_AUDIO_SPLIT_SILENT", }, background: { audioAction: "VIDEO_BACKGROUND_AUDIO_SPLIT", audioSuffix: "背景音", audioSubtitle: "背景音分离", processingLabel: "人声分离", silentAction: "VIDEO_BACKGROUND_AUDIO_SPLIT_SILENT", }, voice: { audioAction: "VIDEO_VOICE_SPLIT", audioSuffix: "人声", audioSubtitle: "人声分离", processingLabel: "人声分离", silentAction: "VIDEO_VOICE_SPLIT_SILENT", }, }; const copy = splitCopy[mode]; const baseTitle = videoNode.title || "视频"; const durationSeconds = getVideoNodeDurationSeconds(videoNode); const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode) || SCRIPT_VIDEO_REFERENCE_URL; const outputSizeLabel = getDraftString(videoNode, "outputSizeLabel") || "1280 × 720"; const existingTrackCount = nodes.filter( (item) => item.inputType === "audio" && item.draftValues?.sourceVideoNodeId === videoNode.id ).length; const existingSilentVideoCount = nodes.filter( (item) => item.inputType === "video" && item.draftValues?.sourceVideoNodeId === videoNode.id && Boolean(item.draftValues?.silentVideo) ).length; const rightX = Math.round(videoNode.x + videoNode.width + OFFICIAL_NODE_GAP); const splitResultY = Math.round( videoNode.y + Math.max(existingTrackCount, existingSilentVideoCount) * (350 + OFFICIAL_NODE_GAP) ); setVideoAudioSplitStates((current) => ({ ...current, [nodeId]: { mode, status: "loading" }, })); setSelectedNodeId(videoNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); toast.info(`${copy.processingLabel}处理中,请稍候…`); window.setTimeout(() => { const audioCreated = createMediaResourceNode({ action: copy.audioAction, height: 148, mediaType: "audio", mediaUrl: AUDIO_SOURCE_URL, title: `${baseTitle}_${copy.audioSuffix}`, width: 350, x: rightX, y: splitResultY, }); const audioNode: CloneNode = { ...audioCreated, subtitle: copy.audioSubtitle, draftValues: { ...(audioCreated.draftValues || {}), durationSeconds, generated: true, sourceVideoNodeId: videoNode.id, sourceVideoTitle: videoNode.title, splitMode: mode, }, }; const silentCreated = createMediaResourceNode({ action: copy.silentAction, height: 350, mediaType: "video", mediaUrl: sourceUrl, title: `${baseTitle}_无声`, width: 350, x: rightX, y: splitResultY, }); const silentVideoNode: CloneNode = { ...silentCreated, subtitle: "无声视频", draftValues: { ...(silentCreated.draftValues || {}), durationSeconds, generated: true, mediaType: "video", nodeType: "video", outputSizeLabel, silentVideo: true, sourceUrl, sourceVideoNodeId: videoNode.id, sourceVideoTitle: videoNode.title, splitMode: mode, }, }; addNodes( [silentVideoNode, audioNode], [ { id: makeId("edge"), source: videoNode.id, target: silentVideoNode.id }, { id: makeId("edge"), source: videoNode.id, target: audioNode.id }, ] ); setSelectedNodeId(null); setSelectedEdgeId(null); setVideoAudioSplitStates((current) => { const next = { ...current }; delete next[nodeId]; return next; }); }, 6400); }, [addNodes, nodes, videoAudioSplitStates] ); const upsertVideoStoryAnalysisNode = useCallback( ( sourceNode: CloneNode, rows: VideoAnalysisRow[], summary: string, options?: { error?: string; framePreviews?: string[] } ) => { const existingStoryNode = nodes.find( (item) => getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && getDraftString(item, "sourceVideoNodeId") === sourceNode.id ); const storyNode = createVideoStoryAnalysisNode({ error: options?.error, existingNode: existingStoryNode, framePreviews: options?.framePreviews, rows, sourceNode, summary, }); setNodes((current) => { const currentExisting = current.find((item) => item.id === storyNode.id); if (currentExisting) { return current.map((item) => (item.id === storyNode.id ? { ...storyNode, x: item.x, y: item.y } : item)); } return [...current, storyNode]; }); setEdges((current) => current.some((edge) => edge.source === sourceNode.id && edge.target === storyNode.id) ? current : [...current, { id: makeId("edge"), source: sourceNode.id, target: storyNode.id }] ); setSelectedNodeId(storyNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); return storyNode.id; }, [nodes] ); 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") { return; } if (videoAnalysisStates[nodeId]?.status === "ready") { const existingStoryNode = nodes.find( (item) => getDraftString(item, "action") === "VIDEO_STORY_ANALYSIS" && getDraftString(item, "sourceVideoNodeId") === node.id ); if (existingStoryNode) { setSelectedNodeId(existingStoryNode.id); setSelectedEdgeId(null); } return; } const definition = getConfiguredFunction(videoCanvasFunctionConfig, "video-analysis"); const prompt = composeFunctionPrompt( definition, [ "请解析当前视频,输出结构化视频故事表。", "每个镜头必须包含:镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词、关键帧建议。", "图像生成提示词和视频运动提示词优先使用英文,其他说明使用中文。", `视频标题:${node.title}`, ].join("\n") ); setActiveVideoToolbarMode(null); 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(" · ") || "已整理出当前视频的镜头节奏与生成建议。"; const framePreviews = response.data?.framePreviews || []; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), videoAnalysis: { framePreviews, rows, summary, }, }, } : item ) ); upsertVideoStoryAnalysisNode(node, rows, summary, { framePreviews }); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { rows, status: "ready", summary, }, })); toast.success("解析完成,已创建视频故事节点"); } catch (error) { const fallbackRows = buildVideoAnalysisRows(node); const errorMessage = error instanceof Error ? error.message : "视频解析失败"; const summary = "解析失败,先展示本地占位结构,方便继续编排。"; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), videoAnalysis: { rows: fallbackRows, summary, }, }, } : item ) ); upsertVideoStoryAnalysisNode(node, fallbackRows, summary, { error: errorMessage }); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { error: errorMessage, rows: fallbackRows, status: "error", summary, }, })); toast.error(errorMessage); } }, [nodes, upsertVideoStoryAnalysisNode, 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); const audioUrls = incomingNodes .filter((item) => item.inputType === "audio") .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 selectedModelRef = normalizeModelRef(settings?.model) || undefined; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成中", draftValues: { ...(item.draftValues || {}), generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", params: { ...getNodeParams(item), functionKey: "story-script", imageUrls, model: selectedModelRef, prompt, route: "canvas-workflow-analyze", videoUrls, }, prompt, }, } : item ) ); const response = await workflowAnalysisService.analyze({ imageUrls, mode: "story-script", nodeTitle: node.title, preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, selectedModelRef), prompt, 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, generationProgress: 100, generationStatus: "complete", prompt, rows: response.data?.analysisRows || 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 requestedMode: VideoGenerationMode = "first-frame"; const referenceImages = selectVideoReferenceImages(imageUrls, requestedMode); if (referenceImages.length < 1) { toast.error(mode === "audio-to-video" ? "音频生视频需要同时接入一张参考图" : "首帧图生视频需要先接入首帧图片"); return; } const ratio = normalizeVideoRatioValue(settings?.ratio); const resolution = normalizeVideoResolutionValue(settings?.resolution); const duration = normalizeVideoDurationValue(settings?.duration); const preferredModelOverride = settings?.model || ""; const preferredModelRef = getFunctionModel(videoCanvasFunctionConfig, definition, preferredModelOverride); const prompt = composeFunctionPrompt(definition, draftPrompt); const previewUrl = referenceImages[0] || getNodeReferenceMediaUrl(node) || ""; const savedSettings: ComposerSettings = { audio: settings?.audio || "开启", camera: settings?.camera || "摄像机", count: settings?.count || "1个", duration: formatVideoDurationLabel(duration), model: normalizeModelRef(preferredModelOverride) || "auto", modelLabel: settings?.modelLabel || "", panorama: settings?.panorama || (mode === "audio-to-video" ? "全能参考" : "图生视频"), ratio: formatVideoRatioLabel(ratio), resolution: formatVideoResolutionLabel(resolution), }; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, kind: "media", inputType: "video", subtitle: "生成中", draftValues: { ...clearGridSplitDraftValues(item.draftValues), action: "VIDEO_GENERATE", generated: false, generationError: undefined, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", params: { ...getNodeParams(item), audioUrls, duration, functionKey: definition.key, mode: requestedMode, model: normalizeModelRef(preferredModelOverride) || undefined, prompt, ratio, referenceImages, resolution, settings: savedSettings, }, poster: previewUrl, prompt, sourceUrl: previewUrl, url: [], }, } : item ) ); const response = await videoGenerationService.generateVideo({ duration, images: referenceImages, mode: requestedMode, preferredModelRef, prompt, ratio, resolution, }); 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, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: response.data?.url, nodeType: "video", outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`, params: { ...getNodeParams(item), audioUrls, duration, functionKey: definition.key, mode: requestedMode, model: normalizeModelRef(preferredModelOverride) || undefined, prompt, ratio, referenceImages, resolution, settings: savedSettings, }, poster: previewUrl, prompt, sourceUrl: previewUrl, url: [response.data?.url], videoMeta: response.data?.metadata || null, }, } : item ) ); toast.success(`${definition.label}已生成`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成失败", draftValues: { ...(item.draftValues || {}), generationError: error instanceof Error ? error.message : "生成失败", generationStatus: "failed", }, } : item ) ); 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"), title: `${node.title} - 副本`, x: node.x, y: node.y + node.height + 60, }; setNodes((current) => [...current, duplicate]); setSelectedNodeId(duplicate.id); setNodeContextMenu(null); setConnectionMenu(null); }, [nodes]); const renameNode = useCallback((nodeId: string, title: string) => { const nextTitle = title.trim(); if (!nextTitle) { toast.error("节点名称不能为空"); return false; } setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, title: nextTitle, } : node ) ); setSelectedNodeId(nodeId); setNodeContextMenu(null); setConnectionMenu(null); toast.success("已重命名节点"); return true; }, []); 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: definition.key === "story-script" ? "story-script" : "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?.analysisRows || 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, options?: { silent?: boolean }) => { 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)); setSelectedEdgeId(null); setInspectorNodeId((current) => (current === nodeId ? null : current)); setNodeContextMenu(null); setConnectionMenu(null); if (!options?.silent) { toast.success("节点已删除"); } }, []); const deleteEdge = useCallback((edgeId: string) => { setEdges((current) => current.filter((edge) => edge.id !== edgeId)); setSelectedEdgeId(null); setLineConnectState(null); setConnectionMenu(null); setNodeContextMenu(null); }, []); const deleteSelectedEdge = useCallback(() => { if (!selectedEdgeId) return false; deleteEdge(selectedEdgeId); return true; }, [deleteEdge, selectedEdgeId]); 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 getCanvasPoint = useCallback( (clientX: number, clientY: number) => ({ x: (clientX - viewport.x) / viewport.zoom, y: (clientY - viewport.y) / viewport.zoom, }), [viewport.x, viewport.y, viewport.zoom] ); const getConnectionHoverNodeId = useCallback((clientX: number, clientY: number, anchorId?: string | null) => { const targetElement = document .elementsFromPoint(clientX, clientY) .map((element) => element.closest("[data-canvas-node-id]")) .find((element): element is HTMLElement => Boolean(element)); const targetNodeId = targetElement?.dataset.canvasNodeId || null; return targetNodeId && targetNodeId !== anchorId ? targetNodeId : null; }, []); const createLineConnectState = useCallback((node: CloneNode | null): LineConnectState => { if (!node) return { cursorCanvasX: null, cursorCanvasY: null, sourceId: null }; return { cursorCanvasX: Math.round(node.x + node.width + OFFICIAL_NODE_GAP), cursorCanvasY: Math.round(node.y + node.height / 2), sourceId: node.id, }; }, []); const updateEndpointConnectionDrag = useCallback( (clientX: number, clientY: number) => { const endpointDrag = connectionDragRef.current; if (!endpointDrag) return false; const canvasPoint = getCanvasPoint(clientX, clientY); const distance = Math.hypot(clientX - endpointDrag.startClientX, clientY - endpointDrag.startClientY); if (distance > 4 || endpointDrag.hasDragged) { endpointDrag.hasDragged = true; suppressConnectionClickRef.current = true; setLineConnectState({ cursorCanvasX: canvasPoint.x, cursorCanvasY: canvasPoint.y, hoverNodeId: getConnectionHoverNodeId(clientX, clientY, endpointDrag.anchorId), sourceId: endpointDrag.side === "source" ? endpointDrag.anchorId : null, targetId: endpointDrag.side === "target" ? endpointDrag.anchorId : null, }); } return endpointDrag.hasDragged; }, [getCanvasPoint, getConnectionHoverNodeId] ); const finishEndpointConnectionDrag = useCallback( (clientX: number, clientY: number) => { const endpointDrag = connectionDragRef.current; if (!endpointDrag) return false; connectionDragRef.current = null; connectionDragCleanupRef.current?.(); connectionDragCleanupRef.current = null; if (!endpointDrag.hasDragged) return false; const targetNodeId = getConnectionHoverNodeId(clientX, clientY, endpointDrag.anchorId) || ""; const connected = targetNodeId && endpointDrag.side === "source" ? connectExistingNodes(endpointDrag.anchorId, targetNodeId, { notify: false }) : targetNodeId ? connectExistingNodes(targetNodeId, endpointDrag.anchorId, { notify: false }) : false; if (!targetNodeId) { toast.info("拖到节点上即可建立连接"); } if (!connected) { setSelectedNodeId(endpointDrag.anchorId); } setLineConnectState(null); return true; }, [connectExistingNodes, getConnectionHoverNodeId] ); const startConnectionEndpointDrag = useCallback( (event: ReactPointerEvent, node: CloneNode, side: ConnectionSide) => { if (event.button !== 0) return; if (connectionDragRef.current) return; event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); connectionDragRef.current = { anchorId: node.id, hasDragged: false, pointerId: event.pointerId, side, startClientX: event.clientX, startClientY: event.clientY, }; setSelectedNodeId(node.id); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); connectionDragCleanupRef.current?.(); const handleWindowPointerMove = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId !== event.pointerId) return; updateEndpointConnectionDrag(pointerEvent.clientX, pointerEvent.clientY); }; const handleWindowPointerUp = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId !== event.pointerId) return; finishEndpointConnectionDrag(pointerEvent.clientX, pointerEvent.clientY); }; const handleWindowMouseMove = (mouseEvent: MouseEvent) => { updateEndpointConnectionDrag(mouseEvent.clientX, mouseEvent.clientY); }; const handleWindowMouseUp = (mouseEvent: MouseEvent) => { finishEndpointConnectionDrag(mouseEvent.clientX, mouseEvent.clientY); }; window.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("pointerup", handleWindowPointerUp, { once: true }); window.addEventListener("mousemove", handleWindowMouseMove); window.addEventListener("mouseup", handleWindowMouseUp, { once: true }); connectionDragCleanupRef.current = () => { window.removeEventListener("pointermove", handleWindowPointerMove); window.removeEventListener("pointerup", handleWindowPointerUp); window.removeEventListener("mousemove", handleWindowMouseMove); window.removeEventListener("mouseup", handleWindowMouseUp); }; }, [finishEndpointConnectionDrag, updateEndpointConnectionDrag] ); const startConnectionEndpointMouseDrag = useCallback( (event: ReactMouseEvent, node: CloneNode, side: ConnectionSide) => { if (event.button !== 0) return; if (connectionDragRef.current) return; event.stopPropagation(); connectionDragRef.current = { anchorId: node.id, hasDragged: false, pointerId: null, side, startClientX: event.clientX, startClientY: event.clientY, }; setSelectedNodeId(node.id); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); connectionDragCleanupRef.current?.(); const handleWindowMouseMove = (mouseEvent: MouseEvent) => { updateEndpointConnectionDrag(mouseEvent.clientX, mouseEvent.clientY); }; const handleWindowMouseUp = (mouseEvent: MouseEvent) => { finishEndpointConnectionDrag(mouseEvent.clientX, mouseEvent.clientY); }; window.addEventListener("mousemove", handleWindowMouseMove); window.addEventListener("mouseup", handleWindowMouseUp, { once: true }); connectionDragCleanupRef.current = () => { window.removeEventListener("mousemove", handleWindowMouseMove); window.removeEventListener("mouseup", handleWindowMouseUp); }; }, [finishEndpointConnectionDrag, updateEndpointConnectionDrag] ); const updateVideoCanvasMagicMarks = useCallback( ( targetNodeId: string, updater: (marks: VideoCanvasMagicMark[]) => VideoCanvasMagicMark[] ) => { setNodes((current) => current.map((item) => { if (item.id !== targetNodeId) return item; const nextMarks = updater(getVideoCanvasMagicMarks(item)); return { ...item, draftValues: { ...(item.draftValues || {}), magicMarks: nextMarks, params: { ...getNodeParams(item), magicMarks: nextMarks, }, }, }; }) ); }, [] ); const analyzeVideoCanvasMark = useCallback( async ({ clickX, clickY, imageUrl, sourceNodeId, targetNodeId, }: VideoCanvasMarkAnalysisState & { imageUrl: string }) => { setMarkAnalysisState({ clickX, clickY, sourceNodeId, targetNodeId }); try { const response = await fetch("/api/pick-object", { body: JSON.stringify({ clickX, clickY, imageData: imageUrl, mode: "video-canvas-mark", }), headers: { "Content-Type": "application/json" }, method: "POST", }); const payload = (await response.json().catch(() => ({}))) as PickObjectApiResponse; if (!response.ok || !payload.success || !payload.object) { throw new Error(payload.error || "元素分析失败"); } const labelOptions = Array.from( new Set( [ ...(Array.isArray(payload.object.labels) ? payload.object.labels : []), payload.object.name || "", ] .map((label) => String(label || "").trim().slice(0, 8)) .filter(Boolean) ) ).slice(0, 4); if (labelOptions.length === 0) throw new Error("元素分析未返回标签"); setNodes((current) => current.map((item) => { if (item.id !== targetNodeId) return item; const currentMarks = getVideoCanvasMagicMarks(item); const nextSequence = currentMarks.reduce((max, mark) => { if (mark.sourceNodeId !== sourceNodeId) return max; const suffix = Number(mark.id.slice(sourceNodeId.length + 1)); return Number.isFinite(suffix) ? Math.max(max, suffix) : max; }, 0) + 1; const nextMarks = [ ...currentMarks, { clickX, clickY, id: `${sourceNodeId}_${nextSequence}`, imageUrl, labelOptions, selectedIndex: 0, sourceNodeId, }, ]; return { ...item, draftValues: { ...(item.draftValues || {}), magicMarks: nextMarks, params: { ...getNodeParams(item), magicMarks: nextMarks, }, }, }; }) ); } catch (error) { toast.error(error instanceof Error ? error.message : "元素分析失败"); } finally { setMarkAnalysisState((current) => current?.sourceNodeId === sourceNodeId && current.targetNodeId === targetNodeId ? null : current ); } }, [] ); const startNodeDrag = useCallback( (event: ReactPointerEvent, node: CloneNode) => { event.stopPropagation(); if (markSelectionTargetId) { event.preventDefault(); const mediaType = getDraftString(node, "mediaType") || node.inputType; const imageUrl = getNodeReferenceMediaUrl(node); const eligible = node.id !== markSelectionTargetId && node.kind === "media" && mediaType === "image" && Boolean(imageUrl) && !markAnalysisState; if (!eligible) return; const imageElement = event.currentTarget.querySelector("img"); const imageRect = imageElement?.getBoundingClientRect(); if ( !imageRect || event.clientX < imageRect.left || event.clientX > imageRect.right || event.clientY < imageRect.top || event.clientY > imageRect.bottom ) { return; } const clickX = Math.max( 0, Math.min(1, (event.clientX - imageRect.left) / imageRect.width) ); const clickY = Math.max( 0, Math.min(1, (event.clientY - imageRect.top) / imageRect.height) ); setSelectedNodeId(markSelectionTargetId); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); void analyzeVideoCanvasMark({ clickX, clickY, imageUrl, sourceNodeId: node.id, targetNodeId: markSelectionTargetId, }); return; } if (referenceSelectionTargetId) { event.preventDefault(); const mediaType = getDraftString(node, "mediaType") || node.inputType; const eligible = node.id !== referenceSelectionTargetId && node.kind === "media" && mediaType === "image" && Boolean(getNodeReferenceMediaUrl(node)); if (!eligible) return; setEdges((current) => { const connected = current.some( (edge) => edge.source === node.id && edge.target === referenceSelectionTargetId ); if (connected) { return current.filter( (edge) => !(edge.source === node.id && edge.target === referenceSelectionTargetId) ); } return [ ...current, { id: makeId("edge"), source: node.id, target: referenceSelectionTargetId }, ]; }); setSelectedNodeId(referenceSelectionTargetId); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); return; } if (lineConnectState) { event.preventDefault(); const canvasPoint = getCanvasPoint(event.clientX, event.clientY); setSelectedNodeId(node.id); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); if (lineConnectState.targetId) { if (connectExistingNodes(node.id, lineConnectState.targetId, { notify: false })) { setLineConnectState(null); } return; } if (!lineConnectState.sourceId) { setLineConnectState({ cursorCanvasX: canvasPoint.x, cursorCanvasY: canvasPoint.y, sourceId: node.id, }); return; } if (connectExistingNodes(lineConnectState.sourceId, node.id, { notify: false })) { setLineConnectState(null); } return; } event.currentTarget.setPointerCapture(event.pointerId); setSelectedNodeId(node.id); setSelectedEdgeId(null); 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, }; }, [ analyzeVideoCanvasMark, connectExistingNodes, getCanvasPoint, lineConnectState, markAnalysisState, markSelectionTargetId, referenceSelectionTargetId, ] ); const startCanvasDrag = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; setSelectedNodeId(null); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setLineConnectState(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.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") { event.preventDefault(); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setInspectorNodeId(null); setSelectedEdgeId(null); setLineConnectState((current) => { if (current) { return null; } const source = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : null; if (source) { return createLineConnectState(source); } return createLineConnectState(null); }); return; } if ((event.key === "Delete" || event.key === "Backspace") && selectedEdgeId) { event.preventDefault(); deleteSelectedEdge(); return; } if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId) { event.preventDefault(); deleteNode(selectedNodeId, { silent: true }); return; } if (event.key === "Escape") { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setSelectedNodeId(null); setSelectedEdgeId(null); setActivePanel(null); setLineConnectState(null); setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setMarkAnalysisState(null); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [createLineConnectState, deleteNode, deleteSelectedEdge, nodes, selectedEdgeId, selectedNodeId]); const onPointerMove = useCallback( (event: ReactPointerEvent) => { const endpointDrag = connectionDragRef.current; if (endpointDrag) { updateEndpointConnectionDrag(event.clientX, event.clientY); return; } if (lineConnectState?.sourceId || lineConnectState?.targetId) { const canvasPoint = getCanvasPoint(event.clientX, event.clientY); setLineConnectState((current) => current?.sourceId || current?.targetId ? { ...current, cursorCanvasX: canvasPoint.x, cursorCanvasY: canvasPoint.y, hoverNodeId: getConnectionHoverNodeId(event.clientX, event.clientY, current.sourceId || current.targetId), } : current ); } 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), })); }, [getCanvasPoint, getConnectionHoverNodeId, lineConnectState?.sourceId, lineConnectState?.targetId, snapEnabled, updateEndpointConnectionDrag, viewport.zoom] ); const onPointerUp = useCallback((event: ReactPointerEvent) => { const endpointDrag = connectionDragRef.current; if (endpointDrag) { if (finishEndpointConnectionDrag(event.clientX, event.clientY)) { event.stopPropagation(); return; } } dragRef.current = null; }, [finishEndpointConnectionDrag]); useEffect(() => { return () => { connectionDragCleanupRef.current?.(); connectionDragCleanupRef.current = null; }; }, []); const setZoomAroundViewportCenter = useCallback((nextZoom: number) => { setViewport((current) => { const zoom = clampCanvasZoom(nextZoom); if (zoom === current.zoom) return current; const clientX = (canvasRef.current?.clientWidth || window.innerWidth) / 2; const clientY = (canvasRef.current?.clientHeight || window.innerHeight) / 2; const worldX = (clientX - current.x) / current.zoom; const worldY = (clientY - current.y) / current.zoom; return { x: Math.round(clientX - worldX * zoom), y: Math.round(clientY - worldY * zoom), zoom, }; }); }, []); const zoomBy = useCallback((delta: number) => { setViewport((current) => { const nextZoom = clampCanvasZoom(current.zoom + delta); if (nextZoom === current.zoom) return current; const clientX = (canvasRef.current?.clientWidth || window.innerWidth) / 2; const clientY = (canvasRef.current?.clientHeight || window.innerHeight) / 2; 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 zoomAtPoint = useCallback((delta: number, clientX: number, clientY: number) => { setViewport((current) => { const nextZoom = clampCanvasZoom(current.zoom + delta); 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(CANVAS_MIN_ZOOM, 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 fitDockTarget = useCallback(() => { fitNodesToView(nodes); setDockOverviewOpen(false); }, [fitNodesToView, nodes]); const handleArrangeCanvas = useCallback(() => { setDockOverviewOpen(false); if (nodes.length === 0) { fitNodesToView(nodes); setArrangePreview(null); return; } const arrangedNodes = arrangeNodesForPreview(nodes); setArrangePreview({ nodes: nodes.map((node) => ({ ...node })), viewport, }); setNodes(arrangedNodes); fitNodesToView(arrangedNodes); }, [fitNodesToView, nodes, viewport]); const restoreArrangePreview = useCallback(() => { if (!arrangePreview) return; setNodes(arrangePreview.nodes); setViewport(arrangePreview.viewport); setArrangePreview(null); }, [arrangePreview]); const keepArrangePreview = useCallback(() => { setArrangePreview(null); }, []); 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 startAssetManagerResize = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); assetManagerResizeCleanupRef.current?.(); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; const handlePointerMove = (moveEvent: PointerEvent) => { setAssetManagerWidth(clampAssetManagerWidth(moveEvent.clientX)); }; const cleanup = () => { document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", cleanup); document.removeEventListener("pointercancel", cleanup); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; assetManagerResizeCleanupRef.current = null; }; document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", cleanup, { once: true }); document.addEventListener("pointercancel", cleanup, { once: true }); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; assetManagerResizeCleanupRef.current = cleanup; }, [] ); const locateCanvasNode = useCallback( (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const canvasWidth = canvasRef.current?.clientWidth || window.innerWidth; const canvasHeight = canvasRef.current?.clientHeight || window.innerHeight; const drawerOffset = activePanel === "assetManager" ? assetManagerWidth : 0; const zoom = 1; setSelectedNodeId(node.id); setSelectedEdgeId(null); setNodeContextMenu(null); setConnectionMenu(null); setLineConnectState(null); setViewport({ x: Math.round(drawerOffset + (canvasWidth - drawerOffset) / 2 - (node.x + node.width / 2) * zoom), y: Math.round(canvasHeight / 2 - (node.y + node.height / 2) * zoom), zoom, }); }, [activePanel, assetManagerWidth, nodes] ); const toggleTopPanel = useCallback((panel: Exclude) => { setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setInspectorNodeId(null); setLineConnectState(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 effectLibraryConnectedNode = useMemo(() => { if (!effectLibraryTargetNodeId) return null; const sourceId = edges.find((edge) => { if (edge.target !== effectLibraryTargetNodeId) return false; return isVideoEffectMaterialNode( nodes.find((node) => node.id === edge.source), ); })?.source; return nodes.find((node) => node.id === sourceId) || null; }, [edges, effectLibraryTargetNodeId, nodes]); const roleLibraryConnectedNodes = useMemo(() => { if (!roleLibraryTargetNodeId) return []; return edges .filter((edge) => edge.target === roleLibraryTargetNodeId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => isVideoRoleMaterialNode(node)); }, [edges, nodes, roleLibraryTargetNodeId]); 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]); const selectedVideoCropGenerateState = selectedVideoMediaNode ? videoCropGenerateStates[selectedVideoMediaNode.id] : undefined; const selectedSubtitleEraseRegions = selectedVideoMediaNode ? subtitleEraseRegions[selectedVideoMediaNode.id] || [] : []; const selectedSubtitleEraseHistory = selectedVideoMediaNode ? subtitleEraseRegionHistory[selectedVideoMediaNode.id] || { future: [], past: [] } : { future: [], past: [] }; const selectEdge = useCallback((event: ReactPointerEvent, edgeId: string) => { event.preventDefault(); event.stopPropagation(); setSelectedEdgeId(edgeId); setSelectedNodeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState(null); }, []); const openStoryScriptWorkflow = useCallback((nodeId: string) => { setStoryWorkflowNodeId(nodeId); setStoryWorkflowStep("shots"); setSelectedNodeId(nodeId); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState(null); }, []); const lineConnectPreview = useMemo(() => { if (!lineConnectState?.sourceId || lineConnectState.cursorCanvasX === null || lineConnectState.cursorCanvasY === null) { if (!lineConnectState?.targetId || lineConnectState?.cursorCanvasX === null || lineConnectState?.cursorCanvasY === null) { return null; } const target = nodes.find((node) => node.id === lineConnectState.targetId); if (!target) return null; return { endX: target.x, endY: target.y + target.height / 2, startX: lineConnectState.cursorCanvasX, startY: lineConnectState.cursorCanvasY, }; } const source = nodes.find((node) => node.id === lineConnectState.sourceId); if (!source) return null; return { endX: lineConnectState.cursorCanvasX, endY: lineConnectState.cursorCanvasY, startX: source.x + source.width, startY: source.y + source.height / 2, }; }, [lineConnectState, nodes]); return (
{lineConnectState ? (
正在跟随
按 ESC 退出
) : null} {referenceSelectionTargetId ? ( setReferenceSelectionTargetId(null)} onReturn={() => locateCanvasNode(referenceSelectionTargetId)} /> ) : null} {markSelectionTargetId ? ( setMarkSelectionTargetId(null)} onReturn={() => locateCanvasNode(markSelectionTargetId)} /> ) : null} {cancelGenerationConfirmNodeId ? ( setCancelGenerationConfirmNodeId(null)} onConfirm={() => setCancelGenerationConfirmNodeId(null)} /> ) : null}
{nodes.map((node) => ( openNodeContext(event, node)} onCancelGeneration={() => cancelGenerationNode(node.id)} onClearGridSplit={() => dismissNodeGridSplit(node.id)} onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)} onClearLocalEditRegions={() => clearNodeLocalEditRegions(node.id)} onExtractVideoFrame={(kind) => extractVideoFrameNode(node.id, kind)} onGenerateVideoHd={() => generateVideoHdUpscaleNode(node.id)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onOpenScriptWorkflow={() => openStoryScriptWorkflow(node.id)} onPointerDown={(event) => startNodeDrag(event, node)} onStartConnectionDrag={(event, side) => startConnectionEndpointDrag(event, node, side)} onStartConnectionMouseDrag={(event, side) => startConnectionEndpointMouseDrag(event, node, side)} onRunNode={() => { const mode = getPresetControlMode(node); if (mode && !isOfficialImagePresetFunctionKey(mode)) { void runPresetComposerSubmit( node.id, mode, getDraftString(node, "prompt") || STORY_SCRIPT_PROMPT, ); return; } void runNode(node.id); }} 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)} /> ))} {referenceSelectionTargetId ? edges .filter((edge) => edge.target === referenceSelectionTargetId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => Boolean(node)) .filter((node) => { const mediaType = getDraftString(node, "mediaType") || node.inputType; return node.kind === "media" && mediaType === "image"; }) .map((node) => (
{ event.preventDefault(); event.stopPropagation(); setEdges((current) => current.filter( (edge) => !(edge.source === node.id && edge.target === referenceSelectionTargetId) ) ); setSelectedNodeId(referenceSelectionTargetId); setSelectedEdgeId(null); }} style={{ height: node.height, left: node.x, top: node.y, width: node.width }} > 取消选择
)) : null} {markSelectionTargetId ? nodes .filter((node) => { const mediaType = getDraftString(node, "mediaType") || node.inputType; return ( node.id !== markSelectionTargetId && node.kind === "media" && mediaType === "image" && Boolean(getNodeReferenceMediaUrl(node)) ); }) .map((sourceNode) => { const targetNode = nodes.find((node) => node.id === markSelectionTargetId); const marks = getVideoCanvasMagicMarks(targetNode).filter( (mark) => mark.sourceNodeId === sourceNode.id ); const analysis = markAnalysisState?.sourceNodeId === sourceNode.id && markAnalysisState.targetNodeId === markSelectionTargetId ? markAnalysisState : null; if (marks.length === 0 && !analysis) return null; return ( updateVideoCanvasMagicMarks(markSelectionTargetId, (current) => current.filter((mark) => mark.id !== markId) ) } /> ); }) : null}
{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); }} onHistorySelect={() => { setActivePanel("historyPicker"); 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 ) } onStoryToolAction={(action) => runStoryScriptToolbarAction(selectedPresetControlNode.id, action)} selectedNode={selectedPresetControlNode} /> ) : null} {selectedGenericImageNode && selectedGenericImageAnchor ? (
runSelectedImageToolAction(selectedGenericImageNode.id, action, option)} />
) : null} {selectedVideoMediaNode && selectedVideoAnchor ? (
{ setActiveVideoToolbarMode(null); void requestVideoAnalysis(selectedVideoMediaNode.id); }} onAudioVideoSplit={() => splitVideoAudioTrack(selectedVideoMediaNode.id, "audio-video")} onHdOption={(option) => createVideoHdUpscaleFromVideo(selectedVideoMediaNode.id, option)} onSubtitleErase={(mode) => { setSubtitleEraseMode(mode); setActiveVideoToolbarMode("subtitleErase"); }} onToggleCrop={() => setActiveVideoToolbarMode((current) => current === "crop" ? null : "crop")} onToggleClip={() => setActiveVideoToolbarMode((current) => current === "clip" ? null : "clip")} onVoiceSplit={(mode) => splitVideoAudioTrack(selectedVideoMediaNode.id, mode)} /> {activeVideoToolbarMode === "crop" ? ( <> applyVideoCropSelection(selectedVideoMediaNode.id)} onClose={() => setActiveVideoToolbarMode(null)} title="退出裁剪" /> ) : null} {activeVideoToolbarMode === "subtitleErase" && subtitleEraseMode === "smart" ? ( applySubtitleEraseSelection(selectedVideoMediaNode.id, "smart")} onClose={() => setActiveVideoToolbarMode(null)} title="关闭去字幕生成器" /> ) : null} {activeVideoToolbarMode === "subtitleErase" && subtitleEraseMode === "region" ? ( <> commitSubtitleEraseRegions(selectedVideoMediaNode.id, [...selectedSubtitleEraseRegions, region]) } regions={selectedSubtitleEraseRegions} /> 0} canReset={selectedSubtitleEraseRegions.length > 0} canUndo={selectedSubtitleEraseHistory.past.length > 0} costLabel={selectedSubtitleEraseRegions.length > 0 ? "0" : "-"} onApply={() => applySubtitleEraseSelection(selectedVideoMediaNode.id, "region")} onClose={() => setActiveVideoToolbarMode(null)} onRedo={() => redoSubtitleEraseRegionChange(selectedVideoMediaNode.id)} onReset={() => commitSubtitleEraseRegions(selectedVideoMediaNode.id, [])} onUndo={() => undoSubtitleEraseRegionChange(selectedVideoMediaNode.id)} regionCount={selectedSubtitleEraseRegions.length} /> ) : null} {activeVideoToolbarMode === "clip" ? ( applyVideoClipSelection(selectedVideoMediaNode.id)} onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)} onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })} /> ) : 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 ? ( updateVideoCanvasMagicMarks(selectedNode.id, (current) => current.filter((mark) => mark.id !== markId) ) } onOpenEffectLibrary={() => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(selectedNode.id); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel("effectLibrary"); setLineConnectState(null); }} onOpenRoleLibrary={() => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(null); setRoleLibraryTargetNodeId(selectedNode.id); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel("videoRoleLibrary"); setVideoRoleAgreementOpen(!videoRoleAgreementAccepted); setLineConnectState(null); }} onRemoveEffect={(sourceId) => { setEdges((current) => current.filter( (edge) => !(edge.source === sourceId && edge.target === selectedNode.id), ), ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onRemoveReference={(sourceId) => { setEdges((current) => current.filter( (edge) => !(edge.source === sourceId && edge.target === selectedNode.id) ) ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onRemoveRole={(sourceId) => { setEdges((current) => current.filter( (edge) => !(edge.source === sourceId && edge.target === selectedNode.id), ), ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onPromptChange={(prompt) => { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: { ...(node.draftValues || {}), prompt, params: { ...((node.draftValues?.params as | Record | undefined) || {}), prompt, }, }, } : node, ), ); }} onStartReferenceSelection={() => { setMarkSelectionTargetId(null); setReferenceSelectionTargetId((current) => current === selectedNode.id ? null : selectedNode.id ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState(null); }} onStartMarkSelection={() => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId((current) => current === selectedNode.id ? null : selectedNode.id ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState(null); }} onUpdateMagicMark={(markId, selectedIndex) => updateVideoCanvasMagicMarks(selectedNode.id, (current) => current.map((mark) => mark.id === markId ? { ...mark, selectedIndex: Math.max( 0, Math.min(mark.labelOptions.length - 1, selectedIndex) ), } : mark ) ) } sourceNodes={selectedTargetSourceNodes} referenceSelectionActive={referenceSelectionTargetId === selectedNode.id} onSubmit={(payload) => { void submitVideoTargetComposer(selectedNode.id, payload); }} /> ) : null} {selectedNode && selectedTargetModuleKind === "audio" && selectedTargetAnchor ? ( ) : null} {storyWorkflowNode ? ( setStoryWorkflowNodeId(null)} onRun={() => { void runNode(storyWorkflowNode.id); }} onStepChange={setStoryWorkflowStep} step={storyWorkflowStep} /> ) : 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); }} /> setActivePanel((current) => current === "assetManager" ? null : "assetManager")} onToggleEdges={() => setEdgesHidden((value) => !value)} onToggleOverview={() => setDockOverviewOpen((value) => !value)} onToggleSnap={() => setSnapEnabled((value) => !value)} onSetZoom={setZoomAroundViewportCenter} onViewportChange={setViewport} onZoomIn={() => zoomBy(CANVAS_ZOOM_STEP)} onZoomOut={() => zoomBy(-CANVAS_ZOOM_STEP)} /> {arrangePreview ? ( ) : null} {activePanel === "add" ? ( setActivePanel(null)} onGallerySelect={() => setActivePanel("assets")} onHistorySelect={() => setActivePanel("historyPicker")} onUploadFiles={addUploadedFiles} /> ) : null} {activePanel === "workflow" ? ( setActivePanel(null)} /> ) : null} {inspectorNode ? ( edge.target === inspectorNode.id).length} onAddReference={(node) => addReferenceModulesForTool(node.id)} onChangeProperty={updateNodeDraft} onClose={() => setInspectorNodeId(null)} onGenerate={(node) => void runConfiguredToolNode(node.id)} /> ) : null} {activePanel === "assets" ? ( setActivePanel(null)} onOpenEffectLibrary={() => setActivePanel("effectLibrary")} onOpenStyleLibrary={() => setActivePanel("styleLibrary")} /> ) : null} {activePanel === "effectLibrary" || activePanel === "styleLibrary" ? ( { setActivePanel(null); setEffectLibraryTargetNodeId(null); }} onApplyStyle={addStyleAssetFromMarket} selectedEffectTitle={getDraftString( effectLibraryConnectedNode, "effectTitle", )} /> ) : null} {activePanel === "history" ? ( setActivePanel(null)} onOpenWatermarkRules={openHistoryWatermarkRules} onSelectNode={setSelectedNodeId} onUseNode={reuseHistoryAssetNode} /> ) : null} {activePanel === "historyPicker" ? ( setActivePanel(null)} onConfirm={addHistoryPickerNodes} /> ) : null} {historyWatermarkRulesOpen ? ( ) : null} {activePanel === "assetManager" ? ( setActivePanel(null)} onDuplicateNode={duplicateNode} onRenameNode={renameNode} onResizeStart={startAssetManagerResize} onSelectNode={locateCanvasNode} panelWidth={assetManagerWidth} /> ) : null} {activePanel === "shortcuts" ? ( setActivePanel(null)} /> ) : null} {activePanel === "tutorial" ? ( setActivePanel(null)} /> ) : null} {activePanel === "roleLibrary" ? ( setActivePanel(null)} roles={videoCanvasFunctionConfig.rolePresets} /> ) : null} {activePanel === "videoRoleLibrary" ? ( <> { setActivePanel(null); setRoleLibraryTargetNodeId(null); setVideoRoleAgreementOpen(false); }} presets={videoCanvasFunctionConfig.rolePresets} selectedRoleIds={roleLibraryConnectedNodes.map((node) => getDraftString(node, "roleId"), )} /> {videoRoleAgreementOpen ? ( { setVideoRoleAgreementAccepted(true); setVideoRoleAgreementOpen(false); try { window.localStorage.setItem( VIDEO_ROLE_AGREEMENT_STORAGE_KEY, "1", ); } catch {} }} onClose={() => { setVideoRoleAgreementOpen(false); setActivePanel(null); setRoleLibraryTargetNodeId(null); }} /> ) : null} ) : 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} {directorConsoleNode ? ( sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)} onTutorialActiveChange={setDirectorTutorialActive} onClose={() => setDirectorConsoleNodeId(null)} tutorialActive={directorTutorialActive} /> ) : null} {directorTutorialActive && !directorConsoleNode ? ( setDirectorTutorialActive(false)} /> ) : 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 getToolbarButtonLabel(item: (typeof TOOLBAR_ITEMS)[number]) { return item.id === "workflow" ? "打开工具箱" : item.label; } function LeftToolbar({ activePanel, onOpen, }: { activePanel: ActivePanel; onOpen: (panel: Exclude) => void; }) { return ( ); } function ZoomDock({ assetManagerOpen = false, assetManagerWidth = ASSET_MANAGER_DEFAULT_WIDTH, compact = false, edgesHidden = false, nodes = [], onArrange, onCenter, onSetZoom, onToggleEdges, onToggleAssetManager, onToggleOverview, onToggleSnap, onViewportChange, overviewOpen = false, selectedNodeId, snapEnabled = true, viewport, onZoomIn, onZoomOut, zoom, }: { assetManagerOpen?: boolean; assetManagerWidth?: number; compact?: boolean; edgesHidden?: boolean; nodes?: CloneNode[]; onArrange: () => void; onCenter: () => void; onToggleEdges?: () => void; onToggleAssetManager?: () => void; onToggleOverview?: () => void; onToggleSnap?: () => void; onSetZoom: (zoom: number) => void; onViewportChange: (viewport: { x: number; y: number; zoom: number }) => void; overviewOpen?: boolean; selectedNodeId?: string | null; snapEnabled?: boolean; viewport: { x: number; y: number; zoom: number }; onZoomIn: () => void; onZoomOut: () => void; zoom: number; }) { const zoomMenuRef = useRef(null); const zoomPercent = Math.round(zoom * 100); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); const [zoomInputValue, setZoomInputValue] = useState(() => String(zoomPercent)); useEffect(() => { setZoomInputValue(String(zoomPercent)); }, [zoomPercent]); useEffect(() => { if (!zoomMenuOpen) return; const onPointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && zoomMenuRef.current?.contains(target)) return; setZoomMenuOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setZoomMenuOpen(false); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [zoomMenuOpen]); const applyZoom = useCallback( (nextZoom: number) => { const clampedZoom = clampCanvasZoom(nextZoom); setZoomInputValue(String(Math.round(clampedZoom * 100))); onSetZoom(clampedZoom); }, [onSetZoom] ); const commitZoomInput = useCallback(() => { const parsedPercent = Number.parseFloat(zoomInputValue.trim().replace("%", "")); if (!Number.isFinite(parsedPercent)) { setZoomInputValue(String(zoomPercent)); return; } applyZoom(parsedPercent / 100); }, [applyZoom, zoomInputValue, zoomPercent]); const renderZoomMenuItem = (label: string, shortcut: string | null, onClick: () => void) => ( ); const zoomButtonWidth = zoomPercent < 100 ? 36 : zoomPercent >= 800 ? 44 : 42; if (compact) { return (
); } return (
{assetManagerOpen ? null : ( )}
{overviewOpen ? ( ) : null}
{zoomMenuOpen ? (
{renderZoomMenuItem("放大", "⌘+", () => applyZoom(zoom + CANVAS_ZOOM_STEP))} {renderZoomMenuItem("缩小", "⌘-", () => applyZoom(zoom - CANVAS_ZOOM_STEP))} {renderZoomMenuItem("适合屏幕", "⌘0", onCenter)}
{renderZoomMenuItem("缩放至50%", null, () => applyZoom(0.5))} {renderZoomMenuItem("缩放至100%", null, () => applyZoom(1))} {renderZoomMenuItem("缩放至800%", null, () => applyZoom(8))}
) : null}
); } function ArrangeCanvasConfirm({ assetManagerOpen, assetManagerWidth = ASSET_MANAGER_DEFAULT_WIDTH, onKeep, onRestore, }: { assetManagerOpen?: boolean; assetManagerWidth?: number; onKeep: () => void; onRestore: () => void; }) { return (

是否保留此次整理结果?

); } function ZoomDockButton({ active, children, label, onClick, wide, }: { active?: boolean; children: ReactNode; label: string; onClick?: () => void; wide?: boolean; }) { return ( ); } function CanvasOverviewPopover({ nodes, onViewportChange, selectedNodeId, viewport, }: { nodes: CloneNode[]; onViewportChange: (viewport: { x: number; y: number; zoom: number }) => void; selectedNodeId: string | null; viewport: { x: number; y: number; zoom: number }; }) { const [draggingPointerId, setDraggingPointerId] = useState(null); const map = 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)); const width = Math.max(1, maxX - minX); const height = Math.max(1, maxY - minY); const scale = Math.min(130 / width, 90 / height); const renderedWidth = width * scale; const renderedHeight = height * scale; return { height, minX, minY, offsetX: (150 - renderedWidth) / 2, offsetY: (110 - renderedHeight) / 2, scale, width, }; }, [nodes]); const panToMiniPoint = useCallback( (event: ReactPointerEvent) => { if (!map) return; const rect = event.currentTarget.getBoundingClientRect(); const worldX = map.minX + (event.clientX - rect.left - map.offsetX) / map.scale; const worldY = map.minY + (event.clientY - rect.top - map.offsetY) / map.scale; onViewportChange({ x: Math.round(window.innerWidth / 2 - worldX * viewport.zoom), y: Math.round(window.innerHeight / 2 - worldY * viewport.zoom), zoom: viewport.zoom, }); }, [map, onViewportChange, viewport.zoom] ); const viewportWidth = typeof window === "undefined" ? 0 : window.innerWidth; const viewportHeight = typeof window === "undefined" ? 0 : window.innerHeight; const miniViewBox = map ? { height: 110 / map.scale, width: 150 / map.scale, x: map.minX - map.offsetX / map.scale, y: map.minY - map.offsetY / map.scale, } : null; const viewportWorldRect = map && viewportWidth > 0 && viewportHeight > 0 ? { height: viewportHeight / viewport.zoom, left: -viewport.x / viewport.zoom, top: -viewport.y / viewport.zoom, width: viewportWidth / viewport.zoom, } : null; const minimapMaskPath = miniViewBox && viewportWorldRect ? [ `M${miniViewBox.x},${miniViewBox.y}h${miniViewBox.width}v${miniViewBox.height}h-${miniViewBox.width}z`, `M${viewportWorldRect.left},${viewportWorldRect.top}h${viewportWorldRect.width}v${viewportWorldRect.height}h-${viewportWorldRect.width}z`, ].join("\n ") : ""; return (
{ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setDraggingPointerId(event.pointerId); panToMiniPoint(event); }} onPointerMove={(event) => { if (draggingPointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); panToMiniPoint(event); }} onPointerUp={(event) => { if (draggingPointerId === event.pointerId) { event.currentTarget.releasePointerCapture(event.pointerId); setDraggingPointerId(null); } }} > Mini Map {map && miniViewBox ? ( Mini Map {nodes.map((node) => ( ))} {minimapMaskPath ? ( ) : null} ) : null}
); } function StylizedBrandLogo({ siteLogo, siteLogoDark, siteName, }: { siteLogo?: string | null; siteLogoDark?: string | null; siteName?: string | null; }) { const resolvedSiteName = resolveSiteName(siteName); const displayLogo = siteLogoDark?.trim() || 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 ArrangeCanvasGlyph() { return ( ); } function EdgeVisibilityGlyph({ hidden }: { hidden: boolean }) { return ( ); } function GridTilesGlyph() { 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 OfficialVideoMediaToolbar({ activeMode, analysisLoading = false, anchor, audioSplitLoading = false, onAnalyze, onAudioVideoSplit, onHdOption, onSubtitleErase, onToggleCrop, onToggleClip, onVoiceSplit, }: { activeMode: VideoToolbarMode; analysisLoading?: boolean; anchor?: PresetControlAnchor; audioSplitLoading?: boolean; onAnalyze: () => void; onAudioVideoSplit: () => void; onHdOption: (option: string) => void; onSubtitleErase: (mode: SubtitleEraseMode) => void; onToggleCrop: () => void; onToggleClip: () => void; onVoiceSplit: (mode: Extract) => void; }) { const [menu, setMenu] = useState(null); return (
} label="剪辑" onClick={() => { setMenu(null); onToggleClip(); }} /> } label="裁剪" onClick={() => { setMenu(null); onToggleCrop(); }} />
} label="高清" onClick={() => setMenu((current) => current === "hd" ? null : "hd")} /> {menu === "hd" ? ( { setMenu(null); onHdOption(option); }} /> ) : null}
} label={analysisLoading ? "解析中" : "解析"} onClick={() => { setMenu(null); onAnalyze(); }} />
} label="智能去字幕" onClick={() => setMenu((current) => current === "subtitle" ? null : "subtitle")} /> {menu === "subtitle" ? (
AI一键去除视频字幕,仅支持中英文字幕
} label="智能擦除" onClick={() => { setMenu(null); onSubtitleErase("smart"); }} /> } label="框选擦除" onClick={() => { setMenu(null); onSubtitleErase("region"); }} />
) : null}
} label={audioSplitLoading ? "分离中" : "音频分离"} onClick={() => setMenu((current) => current === "audio" ? null : "audio")} /> {menu === "audio" ? (
人声分离
} label="仅保留人声" onClick={() => { setMenu(null); onVoiceSplit("voice"); }} /> } label="仅保留背景音" onClick={() => { setMenu(null); onVoiceSplit("background"); }} />
} label="音视频分离" onClick={() => { setMenu(null); onAudioVideoSplit(); }} />
) : null}
); } function OfficialVideoToolbarButton({ active = false, disabled = false, dropdown = false, icon, label, onClick, }: { active?: boolean; disabled?: boolean; dropdown?: boolean; icon: ReactNode; label: string; onClick: () => void; }) { return ( ); } function VideoHdSettingsPopover({ onSelect }: { onSelect: (option: string) => void }) { return (
{["1080P", "2K", "4K"].map((option, index) => ( ))}
); } function VideoToolbarMenuPopover({ children, width, }: { children: ReactNode; width: number; }) { return (
{children}
); } function VideoToolbarSubmenu({ children, width, }: { children: ReactNode; width: number; }) { return (
{children}
); } function VideoToolbarMenuItem({ description, icon, label, onClick, }: { description?: string; icon: ReactNode; label: string; onClick: () => void; }) { return ( ); } function VideoInlineActionBar({ actionLabel, anchor, applyBusyLabel, applyIcon = "check", applyLabel, applyLoading = false, costLabel, onApply, onClose, title, }: { actionLabel: string; anchor?: PresetControlAnchor; applyBusyLabel?: string; applyIcon?: "check" | "upload"; applyLabel: string; applyLoading?: boolean; costLabel?: string; onApply: () => void; onClose: () => void; title: string; }) { const ApplyIcon = applyIcon === "upload" ? Upload : Check; const buttonLabel = applyLoading && applyBusyLabel ? applyBusyLabel : applyLabel; return (
{actionLabel}
{costLabel ? ( {costLabel} ) : null}
); } function VideoCropSelectionOverlay({ anchor, node, }: { anchor: PresetControlAnchor; node: CloneNode; }) { const nodeWidth = Math.max(1, node.width * anchor.zoom); const nodeHeight = Math.max(1, node.height * anchor.zoom); const cardTop = anchor.topY + 32 * anchor.zoom; const cardHeight = Math.max(1, nodeHeight - 32 * anchor.zoom); const cardLeft = anchor.centerX - nodeWidth / 2; const cropLeft = cardLeft + nodeWidth * VIDEO_CROP_RECT.x; const cropTop = cardTop + cardHeight * VIDEO_CROP_RECT.y; const cropWidth = nodeWidth * VIDEO_CROP_RECT.width; const cropHeight = cardHeight * VIDEO_CROP_RECT.height; const handlePoints = [ ["left-0 top-0 -translate-x-1/2 -translate-y-1/2"], ["left-1/2 top-0 -translate-x-1/2 -translate-y-1/2"], ["right-0 top-0 translate-x-1/2 -translate-y-1/2"], ["right-0 top-1/2 -translate-y-1/2 translate-x-1/2"], ["right-0 bottom-0 translate-x-1/2 translate-y-1/2"], ["left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2"], ["left-0 bottom-0 -translate-x-1/2 translate-y-1/2"], ["left-0 top-1/2 -translate-x-1/2 -translate-y-1/2"], ]; return (
{VIDEO_CROP_INLINE_LABEL} {handlePoints.map(([className]) => ( ))}
); } function clampUnit(value: number) { return Math.min(1, Math.max(0, value)); } function getVideoCardOverlayRect(anchor: PresetControlAnchor, node: CloneNode) { const nodeWidth = Math.max(1, node.width * anchor.zoom); const nodeHeight = Math.max(1, node.height * anchor.zoom); const cardTop = anchor.topY + 32 * anchor.zoom; const cardHeight = Math.max(1, nodeHeight - 32 * anchor.zoom); return { height: cardHeight, left: anchor.centerX - nodeWidth / 2, top: cardTop, width: nodeWidth, }; } function normalizeSubtitleEraseRegion( startX: number, startY: number, currentX: number, currentY: number, id: string ): SubtitleEraseRegion { const x = clampUnit(Math.min(startX, currentX)); const y = clampUnit(Math.min(startY, currentY)); const maxX = clampUnit(Math.max(startX, currentX)); const maxY = clampUnit(Math.max(startY, currentY)); return { height: Math.max(0, maxY - y), id, width: Math.max(0, maxX - x), x, y, }; } function VideoSubtitleEraseRegionOverlay({ anchor, node, onAddRegion, regions, }: { anchor: PresetControlAnchor; node: CloneNode; onAddRegion: (region: SubtitleEraseRegion) => void; regions: SubtitleEraseRegion[]; }) { const [draftRegion, setDraftRegion] = useState<{ currentX: number; currentY: number; pointerId: number; startX: number; startY: number; } | null>(null); const cardRect = getVideoCardOverlayRect(anchor, node); const toNormalizedPoint = useCallback( (event: ReactPointerEvent) => ({ x: clampUnit((event.clientX - cardRect.left) / cardRect.width), y: clampUnit((event.clientY - cardRect.top) / cardRect.height), }), [cardRect.height, cardRect.left, cardRect.top, cardRect.width] ); const renderedRegions = draftRegion ? [ ...regions, normalizeSubtitleEraseRegion( draftRegion.startX, draftRegion.startY, draftRegion.currentX, draftRegion.currentY, "draft" ), ] : regions; const handlePoints = [ "left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize", "right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize", "right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-se-resize", "left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize", ]; return (
{ if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const point = toNormalizedPoint(event); event.currentTarget.setPointerCapture(event.pointerId); setDraftRegion({ currentX: point.x, currentY: point.y, pointerId: event.pointerId, startX: point.x, startY: point.y, }); }} onPointerMove={(event) => { if (!draftRegion || draftRegion.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); const point = toNormalizedPoint(event); setDraftRegion((current) => current && current.pointerId === event.pointerId ? { ...current, currentX: point.x, currentY: point.y } : current ); }} onPointerUp={(event) => { if (!draftRegion || draftRegion.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); const point = toNormalizedPoint(event); const region = normalizeSubtitleEraseRegion( draftRegion.startX, draftRegion.startY, point.x, point.y, makeId("subtitle-region") ); setDraftRegion(null); if (region.width >= 0.015 && region.height >= 0.015) { onAddRegion(region); } }} style={{ height: cardRect.height, left: cardRect.left, top: cardRect.top, width: cardRect.width, }} > {renderedRegions.map((region) => (
{handlePoints.map((className) => ( ))}
))}
); } function VideoSubtitleEraseRegionActionBar({ anchor, canRedo, canReset, canUndo, costLabel, onApply, onClose, onRedo, onReset, onUndo, regionCount, }: { anchor?: PresetControlAnchor; canRedo: boolean; canReset: boolean; canUndo: boolean; costLabel: string; onApply: () => void; onClose: () => void; onRedo: () => void; onReset: () => void; onUndo: () => void; regionCount: number; }) { return (
event.stopPropagation()} style={ anchor ? { left: anchor.centerX, top: anchor.bottomY + 12, transform: "translateX(-50%)", } : { left: "50%", top: 240, transform: "translateX(-50%)", } } >
框选擦除 使用方法 在画面上拖拽鼠标,框选要擦除的区域 支持框选多个区域,所有框内文字将被擦除 擦除作用于整段视频,不仅是当前帧
} onClick={onUndo} /> } onClick={onRedo} /> } onClick={onReset} />
{costLabel}
); } function VideoRegionBarIconButton({ ariaLabel, disabled, icon, onClick, }: { ariaLabel: string; disabled: boolean; icon: ReactNode; onClick: () => void; }) { return ( ); } function VideoAnalysisGlyph() { return ( ); } function VideoSubtitleErasePendingCanvasNode({ node, onCancel, selected, }: { node: CloneNode; onCancel: () => void; selected: boolean; }) { const progress = getDraftNumber(node, "generationProgress", 0); const progressLabel = progress > 2 ? `生成中 ${Math.round(progress)}%...` : "生成中..."; return (
{node.title}
{progressLabel}
); } function VideoHdUpscaleCanvasNode({ node, onGenerate, selected, }: { node: CloneNode; onGenerate: () => void; selected: boolean; }) { const generationStatus = getDraftString(node, "generationStatus"); const isGenerating = generationStatus === "pending"; const generated = Boolean(node.draftValues?.generated); const mediaUrl = getNodeMediaUrl(node); const progress = getDraftNumber(node, "generationProgress", 0); const resolution = getDraftString(node, "hdResolution") || "1080P"; const outputSizeLabel = getDraftString(node, "outputSizeLabel") || "1920 × 1080"; return (
{node.title}
{generated && mediaUrl ? (
生成结果 {outputSizeLabel}
) : ( <>
{isGenerating ? ( ) : ( )}
配置参数
{isGenerating ? (
) : null} )}
); } function VideoStoryCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) { const rows = getVideoStoryRows(node); const previewUrl = getDraftString(node, "keyframeUrl"); const summary = getDraftString(node, "summary"); const error = getDraftString(node, "error"); const headers = [ "镜号", "开始时间", "结束时间", "时长", "画面描述", "叙事内容", "景别", "摄影机角度", "摄影机运动", "焦距与景深", "光线", "背景音乐", "人声/音效", "图像生成提示词", "视频运动提示词", "关键帧", ]; return (
视频故事
{rows.length || 1} 镜
{summary || error ? (
{error || summary}
) : null}
{headers.map((header) => (
{header}
))}
{rows.length > 0 ? ( rows.map((row) => { const cells = [ row.shot, row.startTime, row.endTime, row.duration, row.sceneDescription, row.narrative, row.shotSize, row.cameraAngle, row.cameraMovement, row.focusDepth, row.lighting, row.backgroundMusic, row.voiceAndSfx, row.imagePrompt, row.motionPrompt, ]; return (
{cells.map((cell, index) => (
{cell || "-"}
))}
{row.keyframeUrl || previewUrl ? ( ) : ( - )}
); }) ) : (
暂无视频故事
)}
); } 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, onStoryToolAction, 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; onStoryToolAction: (action: StoryToolbarAction) => 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 (
onStoryToolAction("regenerate") }, { label: "批量生成分镜", onClick: () => onStoryToolAction("create-storyboard") }, { label: "批量生视频", onClick: () => onStoryToolAction("batch-video") }, ]} />
); } 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}