"use client"; import { AudioLines, ArrowLeft, ArrowUp, Bell, BookOpen, BoxSelect, Camera, Check, ChevronDown, ChevronRight, Clock3, CircleDot, Command, Copy, Delete as DeleteIcon, Download, Ellipsis, FileText, FolderOpen, GitBranch, Gem, Headphones, Hand, HelpCircle, Image as ImageIcon, Keyboard, Languages, Layers, Loader2, Maximize2, MessageCircle, Minimize2, Minus, Music2, Move, Mouse, PanelLeftClose, Pause, Pencil, Plus, Play, Redo2, Repeat2, RotateCcw, Search, Scissors, Settings2, SlidersHorizontal, Sparkles, Spline, Star, Trash2, Upload, UserRound, Video, Volume2, VolumeX, Wand2, Waves, Undo2, Zap, X, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; 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 SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { applyImageModelCreditDiscount, 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 { removeVideoReferenceEdge, resolveVideoReferenceSelectionRule, resolveVideoReferenceToggleAction, selectVideoReferenceImages, toggleVideoReferenceEdge, } from "@/lib/canvas/video-reference-selection"; import { CANVAS_GRID_SNAP_STORAGE_KEY, resolveCanvasGridSnapPosition, } from "@/lib/canvas/grid-snap"; import { CANVAS_PROJECT_TITLE_MAX_LENGTH, normalizeCanvasProjectTitle, } from "@/lib/canvas/project-title"; import { DEFAULT_VIDEO_CANVAS_NAME, normalizeVideoCanvasName, } from "@/lib/canvas/video-workspace"; import { applyCanvasArrangeUpdates, arrangeCanvasNodes, getArrangeCanvasViewport, } from "@/lib/canvas/arrange"; import { clampWorkspacePanelWidth, getInitialWorkspacePanelWidth, WORKSPACE_PANEL_MAX_WIDTH, WORKSPACE_PANEL_MIN_WIDTH, WORKSPACE_PANEL_WIDTH_STORAGE_KEY, } from "@/lib/canvas/asset-manager"; import { CANVAS_MAX_ZOOM, CANVAS_MIN_ZOOM, type CanvasZoomDirection, clampCanvasZoom, sanitizeCanvasZoomInput, stepCanvasZoom, } from "@/lib/canvas/zoom"; import { type CanvasShortcutHelpToken, VIDEO_CANVAS_SHORTCUT_HELP_GROUPS, } from "@/lib/canvas/shortcut-help"; import { attachCanvasNodesToGroup, collectCanvasGroupDescendantIds, detachCanvasGroups, duplicateCanvasNodeSelection, duplicateCanvasNodeTree, expandCanvasGroupSelection, getCanvasGroupBounds, getCanvasGroupChildIds, isCanvasGroupNode, mergeCanvasStoryboardGroups, } from "@/lib/canvas/grouping"; import { isCanvasKeyboardPanKey, shouldStartCanvasPointerPan, } from "@/lib/canvas/pan"; import { cn, fetcher } from "@/lib/utils"; import { VideoCanvasStoryboardCompositionEditor } from "@/components/canvas/VideoCanvasStoryboardCompositionEditor"; import { audioResourceService, canvasService, imageGenerationService, videoGenerationService, videoResourceService, workflowAnalysisService, } from "@/services/canvas"; import { AUDIO_PLAYBACK_RATE_STEP, MAX_AUDIO_PLAYBACK_RATE, MIN_AUDIO_PLAYBACK_RATE, formatAudioPlaybackRate, getAudioDownloadExtension, getDefaultAudioTrimRange, getProcessedAudioTitle, normalizeAudioPlaybackRate, normalizeAudioTrimRange, stepAudioPlaybackRate, type AudioToolRequest, } from "@/lib/video-canvas/audio-tools"; import { processCanvasAudio } from "@/lib/video-canvas/audio-processing"; import { DEFAULT_VIDEO_CLIP_RANGE, getVideoClipDuration, normalizeVideoClipRange, snapVideoClipRatio, type VideoClipRange, } from "@/lib/video-canvas/video-clip"; import { extractCanvasVideoFrame, loadCanvasVideoClipFrames, inspectCanvasVideoAudioTrack, processCanvasVideoClip, processCanvasVideoAudioSplit, processCanvasVideoCrop, processCanvasVideoSubtitleErase, processCanvasVideoUpscale, } from "@/lib/video-canvas/video-processing"; import { getVideoFrameExtractionCopy, type VideoFrameExtractionKind, } from "@/lib/video-canvas/video-frame-extraction"; import { getVideoDownloadFilename, resolveVideoDownloadRuleCopy, type VideoCanvasVideoDownloadConfig, VIDEO_WATERMARK_RULES_STORAGE_KEY, } from "@/lib/video-canvas/video-download"; import { getVideoAudioSplitResultCopy, type VideoAudioSplitMode, } from "@/lib/video-canvas/video-audio-separation"; import { DEFAULT_VIDEO_CROP_RECT, getVideoCropOutputDimensions, moveVideoCropRect, normalizeVideoCropRect, resizeVideoCropRect, type VideoCropHandle, type VideoCropRect, } from "@/lib/video-canvas/video-crop"; import { formatVideoHdFrameRate, getVideoHdCost, type VideoHdInterpolationMode, type VideoHdResolution, } from "@/lib/video-canvas/video-hd"; import { moveSubtitleEraseRegion, normalizeSubtitleEraseRegion, resizeSubtitleEraseRegion, type SubtitleEraseMode, type SubtitleEraseRegion, type SubtitleEraseRegionHandle, } from "@/lib/video-canvas/subtitle-erase"; import { VIDEO_STORY_NODE_HEIGHT, VIDEO_STORY_NODE_WIDTH, getVideoStoryPlacement, } from "@/lib/video-canvas/video-analysis"; import type { CanvasInitialProject, CanvasState } from "@/types/canvas"; import { getVideoCanvasFunctionBillingCopy, type VideoCanvasFunctionConfig, type VideoCanvasFunctionDefinition, type VideoCanvasEffectPreset, type VideoCanvasMotionPreset, type VideoCanvasRolePreset, type VideoCanvasSmartStoryboardConfig, type VideoCanvasSubjectLibraryConfig, type VideoCanvasSubjectVoicePreset, defaultVideoCanvasFunctionConfig, defaultVideoCanvasMotionPresets, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import { buildVideoCanvasImageCreationParams, buildVideoCanvasVideoCreationParams, defaultVideoCanvasNodeCreationConfig, getInitialVideoCanvasNodeCreationOrdinal, type VideoCanvasNodeCreationConfig, } from "@/lib/video-canvas/node-creation"; import { VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS, VIDEO_CANVAS_HISTORY_ZOOM_LEVELS, allocateVideoCanvasHistoryMediaNodeTitle, getInitialVideoCanvasHistoryMediaOrdinals, getVideoCanvasHistoryMediaNodeDimensions, getVideoCanvasHistoryMediaNodePosition, getVideoCanvasHistoryPickerMediaTypes, getVideoCanvasHistoryPickerTitle, getVideoCanvasHistoryZoomIndex, groupVideoCanvasHistoryItems, paginateVideoCanvasHistoryItems, type VideoCanvasHistoryMediaOrdinals, type VideoCanvasHistoryPickerMediaMode, type VideoCanvasHistoryMediaType, } from "@/lib/video-canvas/history-assets"; import { VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE, getVideoCanvasUploadedResourceUrl, validateVideoCanvasUploadedResource, } from "@/lib/video-canvas/uploaded-resource"; import { getVideoCanvasTutorialSupportQr, type VideoCanvasTutorialSupportConfig, type VideoCanvasTutorialSupportQrKind, } from "@/lib/video-canvas/tutorial-support"; import { mergeVideoCanvasAdvancedSettings, resolveVideoCanvasAdvancedSettings, } from "@/lib/video-canvas/video-advanced-settings"; import { defaultVideoCanvasGenerationParametersConfig, estimateVideoCanvasGenerationParameterPoints, getVideoCanvasGenerationCount, mergeVideoCanvasGenerationParameterSettings, resolveVideoCanvasGenerationParameterSettings, type VideoCanvasGenerationParameterSettings, type VideoCanvasGenerationParametersConfig, } from "@/lib/video-canvas/video-generation-parameters"; import { estimateVideoCanvasModelCatalogPoints, getVideoCanvasModelCatalogEntry, resolveVideoCanvasCatalogModelRef, type VideoCanvasModelCatalogEntry, type VideoCanvasModelIconKey, } from "@/lib/video-canvas/video-model-catalog"; import { getVideoCanvasToolboxGroupOrigin, type VideoCanvasToolboxConfig, type VideoCanvasToolboxTemplate, } from "@/lib/video-canvas/toolbox-workflows"; import { getVideoCanvasAttachedEffect, setVideoCanvasAttachedEffect, type VideoCanvasAttachedEffect, } from "@/lib/video-canvas/video-effects"; import { composeVideoCanvasRolePrompt, getVideoCanvasAttachedRole, setVideoCanvasAttachedRole, type VideoCanvasAttachedRole, type VideoCanvasRoleMaterialKind, VIDEO_CANVAS_ROLE_MATERIALS, } from "@/lib/video-canvas/video-roles"; import { addVideoCanvasAttachedSubject, getVideoCanvasAttachedSubjects, removeVideoCanvasAttachedSubject, supportsVideoCanvasSubjectUsage, toVideoCanvasAttachedSubject, type VideoCanvasAttachedSubject, } from "@/lib/video-canvas/subjects"; import { getVideoCanvasSmartStoryboard, normalizeVideoCanvasSmartStoryboard, setVideoCanvasSmartStoryboard, supportsVideoCanvasSmartStoryboard, validateVideoCanvasSmartStoryboard, type VideoCanvasSmartStoryboard, type VideoCanvasSmartStoryboardShot, } from "@/lib/video-canvas/smart-storyboard"; import { normalizeVideoPromptMentions, type VideoPromptMention, } from "@/lib/video-canvas/prompt-structure"; import { composeVideoCanvasMotionPrompt, normalizeVideoCanvasMotionMentions, type VideoCanvasMotionMention, } from "@/lib/video-canvas/video-motion"; import { composeVideoMarkGenerationPrompt } from "@/lib/video-canvas/video-marks"; import { filterVideoCanvasKeyElements, filterVideoCanvasStoryboardVideos, getVideoCanvasStoryboardColumnLayout, getVideoCanvasKeyElementCategoryLabel, projectVideoCanvasStoryboard, projectVideoCanvasStoryboardCompositions, resolveVideoCanvasViewMode, type VideoCanvasKeyElementCategory, type VideoCanvasKeyElementFilter, type VideoCanvasStoryboardExpandedPanel, type VideoCanvasStoryboardItem, type VideoCanvasStoryboardVideoFilter, type VideoCanvasViewMode, VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS, VIDEO_CANVAS_STORYBOARD_VIDEO_FILTER_OPTIONS, VIDEO_CANVAS_VIEW_MODE_KEY, } from "@/lib/video-canvas/storyboard-overview"; import { normalizeVideoCanvasStoryboardComposition, type VideoCanvasStoryboardCompositionProject, } from "@/lib/video-canvas/storyboard-composition"; import { renderVideoCanvasStoryboardComposition } from "@/lib/video-canvas/storyboard-composition-render"; import type { VideoCanvasStoryboardReferenceRequest } from "@/lib/video-canvas/storyboard-reference"; import { collectVideoCanvasDirectorCaptures, isPortableDirectorCaptureUrl, normalizeVideoCanvasDirectorProject, type VideoCanvasDirectorProject, } from "@/lib/video-canvas/director-project"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; const VideoCanvasAgentPanel = dynamic( () => import("@/components/canvas/CanvasChatPanel").then( (module) => module.CanvasChatPanel, ), { loading: () => (
正在加载对话面板...
), ssr: false, }, ); type ActivePanel = | "add" | "workflow" | "assets" | "assetManager" | "effectLibrary" | "history" | "historyPicker" | "help" | "roleLibrary" | "videoRoleLibrary" | "videoSubjectLibrary" | "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 ProcessAudioNode = (node: CloneNode, request: AudioToolRequest) => Promise; 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 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"; nodes: Array<{ id: string; x: number; y: number }>; startX: number; startY: number; } | { kind: "canvas"; startX: number; startY: number; viewportX: number; viewportY: number; }; type CanvasCommandState = { canvasX: number; canvasY: number; screenX: number; screenY: number; } | null; type CanvasShortcutSnapshot = { edges: CloneEdge[]; nodes: CloneNode[]; selectedNodeIds: string[]; viewport: typeof DEFAULT_VIEWPORT; }; 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; motionMentions: VideoCanvasMotionMention[]; 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 VideoWorkspaceCanvasItem = { createdAt: string; id: string; isCurrent: boolean; name: string; }; type VideoWorkspaceCanvasResponse = { data?: { canvases: VideoWorkspaceCanvasItem[]; currentCanvasId: string; workspaceTitle: string; }; error?: string; success: boolean; }; 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 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; }; type VideoAudioTrackAvailability = "checking" | "available" | "unavailable"; 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 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; }; function isVideoCanvasCustomMotionPreset( preset: VideoCanvasMotionPreset | VideoCanvasCustomMotionPreset, ): preset is VideoCanvasCustomMotionPreset { return "custom" in preset && preset.custom === true; } 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 = 12000; const CANVAS_CONTROL_FONT_FAMILY = '-apple-system, system-ui, "PingFang SC", Inter, "Noto Sans SC", "Microsoft YaHei", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; const ASSET_MANAGER_DEFAULT_WIDTH = 240; const OFFICIAL_NODE_GAP = 100; const OFFICIAL_TEXT_RESOURCE_HEIGHT = 330; const OFFICIAL_TEXT_RESOURCE_WIDTH = 560; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; const OFFICIAL_COMPOSER_SCALE = 0.546; const OFFICIAL_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 STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4"; const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid"; const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25"; const CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY = "cinematic-lighting-correction"; const CHARACTER_THREE_VIEW_FUNCTION_KEY = "character-three-view"; const FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY = "frame-prediction-plus-3s"; const FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY = "frame-prediction-minus-5s"; const PROMPT_COMPOSER_BASE_WIDTH = 895; const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PRESET_COMPOSER_BASE_HEIGHT = 238; const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [ { fit: "contain", objectPosition: "50% 50%", scale: 0.62 }, { fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 }, { fit: "contain", objectPosition: "68% 50%", scale: 0.86, translateX: -18 }, { objectPosition: "34% 24%", scale: 1.4, translateX: 8, translateY: 8 }, { objectPosition: "52% 28%", scale: 1.55, translateY: 5 }, { objectPosition: "66% 26%", scale: 1.72, translateX: -8, translateY: 2 }, { objectPosition: "20% 72%", scale: 2.08, translateX: 22, translateY: -12 }, { objectPosition: "52% 68%", scale: 1.24, translateY: -4 }, { objectPosition: "70% 64%", scale: 1.46, translateX: -14, translateY: -6 }, ]; const TARGET_AVATAR = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg"; const CHARACTER_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png"; const CHARACTER_THREE_VIEW_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png"; const FIRST_FRAME_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/first-frame-image.png"; const AUDIO_SOURCE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-source.mp3"; const AUDIO_REFERENCE_IMAGE_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-reference-image.png"; const PUBLISH_MODAL_BG_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png"; const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; const AUDIO_TO_VIDEO_PROMPT = defaultVideoCanvasFunctionConfig.functions["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 formatStoryboardTextGenerationResult( result: { analysisRows?: Array<{ backgroundMusic?: string; cameraMovement?: string; dialogue?: string; duration?: string | number; narrative?: string; sceneDescription?: string; shot?: string | number; story?: string; voiceAndSfx?: string; }>; raw?: string; summary?: string; }, title: string, ) { const raw = String(result.raw || "").trim(); if (raw && !/^[{[]/.test(raw)) return raw; const rows = Array.isArray(result.analysisRows) ? result.analysisRows : []; if (rows.length > 0) { const sections = rows.map((row, index) => { const shot = String(row.shot || index + 1).trim(); const scene = String( row.sceneDescription || row.narrative || row.story || "", ).trim(); const sound = String( row.dialogue || row.voiceAndSfx || row.backgroundMusic || "", ).trim(); const movement = String(row.cameraMovement || "").trim(); const duration = String(row.duration || "").trim(); return [ `## 镜头 ${shot}${duration ? ` · ${duration}` : ""}`, scene, sound ? `**声音:** ${sound}` : "", movement ? `**运镜:** ${movement}` : "", ] .filter(Boolean) .join("\n\n"); }); return [ `# ${title}`, String(result.summary || "").trim(), ...sections, ] .filter(Boolean) .join("\n\n"); } return String(result.summary || raw).trim(); } 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 restoreSelectedNodeIds(canvasData: unknown, nodes: CloneNode[]) { const selectedNodeIds = (canvasData as { selectedNodeIds?: unknown[] } | null) ?.selectedNodeIds; if (!Array.isArray(selectedNodeIds)) return []; const availableNodeIds = new Set(nodes.map((node) => node.id)); return selectedNodeIds .map((nodeId) => (typeof nodeId === "string" ? nodeId : "")) .filter((nodeId) => availableNodeIds.has(nodeId)); } function restoreViewport(canvasData: unknown) { const value = (canvasData as { viewport?: unknown } | null)?.viewport; if (!value || typeof value !== "object" || Array.isArray(value)) { return DEFAULT_VIEWPORT; } const record = value as Record; const x = Number(record.x); const y = Number(record.y); const zoom = Number(record.zoom); return { x: Number.isFinite(x) ? x : DEFAULT_VIEWPORT.x, y: Number.isFinite(y) ? y : DEFAULT_VIEWPORT.y, zoom: Number.isFinite(zoom) ? Math.max(CANVAS_MIN_ZOOM, Math.min(CANVAS_MAX_ZOOM, zoom)) : DEFAULT_VIEWPORT.zoom, }; } function getVideoCanvasPersistenceSignature( nodes: CloneNode[], edges: CloneEdge[], viewport: typeof DEFAULT_VIEWPORT, viewMode: VideoCanvasViewMode, ) { return JSON.stringify({ edges, nodes, viewMode, viewport }); } 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({ config, title, x, y, }: { config: VideoCanvasToolboxConfig; title: string; x: number; y: number; }): CloneNode { return { id: makeId("group"), kind: "workflow", title: "分组 3 个节点", subtitle: title, x, y, width: config.layout.groupWidth, height: config.layout.groupHeight, 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, options: { nodeCreation?: VideoCanvasNodeCreationConfig; videoModelCatalog?: VideoCanvasModelCatalogEntry[]; } = {}, ): 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 nodeCreation = options.nodeCreation || defaultVideoCanvasNodeCreationConfig; const isSquareModule = item.nodeType === "text" || item.nodeType === "audio"; const isImageModule = item.nodeType === "image"; const isVideoModule = item.nodeType === "video"; const params = isImageModule ? buildVideoCanvasImageCreationParams(nodeCreation) : isVideoModule ? buildVideoCanvasVideoCreationParams( nodeCreation, options.videoModelCatalog || [], ) : undefined; return { id: makeId(kind), kind: item.kind, title: item.title, subtitle: item.subtitle, inputType: item.nodeType, x, y, width: isImageModule ? nodeCreation.image.width : isVideoModule ? nodeCreation.video.width : isSquareModule ? 350 : 436, height: isImageModule ? nodeCreation.image.height : isVideoModule ? nodeCreation.video.height : isSquareModule ? 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, params, poster: item.nodeType === "video" ? "" : undefined, url: item.nodeType === "text" ? undefined : [], }, }; } function createUploadedMediaNode( file: File, mediaUrl: string, x: number, y: number, index: number, assetId?: string | null, ): 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, assetId: assetId || undefined, generated: false, mediaType, mediaUrl, nodeType: mediaType, resourceMeta: { items: [{ name: file.name, size: file.size, type: file.type }], }, url: [mediaUrl], }, }; } async function uploadVideoCanvasResourceFile(file: File) { const body = new FormData(); body.append("file", file); body.append("purpose", VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE); body.append("clientUploadId", crypto.randomUUID()); const response = await fetch("/api/files/upload", { body, method: "POST", }); const payload = (await response.json().catch(() => null)) as | { assetId?: string | null; error?: string; url?: string } | null; const mediaUrl = getVideoCanvasUploadedResourceUrl(payload); if (!response.ok || !mediaUrl) { throw new Error(payload?.error || "上传失败"); } return { assetId: payload?.assetId || null, 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], }, }; } type VideoCanvasHistoryAsset = { createdAt: string; displayName: string | null; durationMs: number | null; height: number | null; id: string; mediaType: VideoCanvasHistoryMediaType; metadata: unknown; mimeType: string | null; modelId: string | null; originalUrl: string; previewUrl: string | null; prompt: string | null; sourceNodeId: string | null; sourceType: string; thumbnailUrl: string | null; width: number | null; }; type VideoCanvasHistoryAssetsResponse = { assets?: VideoCanvasHistoryAsset[]; error?: string; success?: boolean; }; type VideoCanvasHistoryItem = { asset: VideoCanvasHistoryAsset; createdAt: string; node: CloneNode; }; function readVideoCanvasHistoryAssetMetadata(asset: VideoCanvasHistoryAsset) { return asset.metadata && typeof asset.metadata === "object" && !Array.isArray(asset.metadata) ? (asset.metadata as Record) : {}; } function getVideoCanvasHistoryAssetUrl(asset: VideoCanvasHistoryAsset) { return asset.previewUrl || asset.originalUrl; } function getVideoCanvasHistoryAssetThumbnailUrl( asset: VideoCanvasHistoryAsset, ) { return asset.thumbnailUrl || asset.previewUrl || asset.originalUrl; } function createVideoCanvasHistoryAssetNode({ asset, canvasNodes, index, }: { asset: VideoCanvasHistoryAsset; canvasNodes: CloneNode[]; index: number; }): CloneNode { const mediaUrl = getVideoCanvasHistoryAssetUrl(asset); const metadata = readVideoCanvasHistoryAssetMetadata(asset); const matchingCanvasNode = canvasNodes.find( (node) => (asset.sourceNodeId && node.id === asset.sourceNodeId) || getNodeMediaUrl(node) === asset.originalUrl || getNodeReferenceMediaUrl(node) === asset.originalUrl, ); const label = asset.mediaType === "video" ? "视频" : asset.mediaType === "audio" ? "音频" : "图片"; const displayName = String( metadata.nodeTitle || metadata.title || asset.displayName || "", ) .replace(/\.[^.]+$/, "") .trim(); const title = displayName || `${label}${index + 1}`; const historyDraft = { historyAssetCreatedAt: asset.createdAt, historyAssetHeight: asset.height, historyAssetId: asset.id, historySourceNodeId: asset.sourceNodeId, historyAssetWidth: asset.width, mediaType: asset.mediaType, mediaUrl, model: asset.modelId || undefined, pathname: asset.originalUrl, prompt: asset.prompt || "", thumbnailUrl: getVideoCanvasHistoryAssetThumbnailUrl(asset), url: [mediaUrl], }; if (matchingCanvasNode) { return { ...matchingCanvasNode, draftValues: { ...(matchingCanvasNode.draftValues || {}), ...historyDraft, }, }; } const generated = asset.sourceType !== "upload"; const aspectRatio = asset.width && asset.height ? asset.width / asset.height : null; const generatedHeight = asset.mediaType === "image" && aspectRatio ? Math.max(280, Math.min(560, Math.round(350 / aspectRatio))) : 350; const width = generated ? asset.mediaType === "video" ? 622 : 350 : asset.mediaType === "audio" ? 430 : asset.mediaType === "video" ? 560 : 420; const height = generated ? asset.mediaType === "image" ? generatedHeight : 350 : asset.mediaType === "audio" ? 170 : asset.mediaType === "video" ? 315 : aspectRatio ? Math.max(240, Math.min(560, Math.round(420 / aspectRatio))) : 280; return { accent: asset.mediaType === "audio" ? "#9ee6bb" : asset.mediaType === "video" ? "#8b96ff" : "#8790ff", draftValues: { action: `${asset.mediaType.toUpperCase()}_${generated ? "GENERATE" : "RESOURCE"}`, alt: title, generated, generatorType: generated ? "DEFAULT" : undefined, nodeType: asset.mediaType, params: asset.prompt ? { prompt: asset.prompt } : undefined, ...historyDraft, }, height, id: `history-asset-${asset.id}`, inputType: asset.mediaType, kind: generated ? "model" : "media", subtitle: `${label}${generated ? "生成" : "资源"}`, title, width, x: 0, y: 0, }; } 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 RetriableCanvasImage({ alt, className, rotation = 0, src, }: { alt: string; className: string; rotation?: number; src: string; }) { const [retryAttempt, setRetryAttempt] = useState(0); const retryTimerRef = useRef(null); useEffect(() => { setRetryAttempt(0); return () => { if (retryTimerRef.current !== null) { window.clearTimeout(retryTimerRef.current); } }; }, [src]); const retrySrc = useMemo(() => { if (retryAttempt === 0 || !/^https?:\/\//i.test(src)) return src; const separator = src.includes("?") ? "&" : "?"; return `${src}${separator}canvas_image_retry=${retryAttempt}`; }, [retryAttempt, src]); const clearRetryTimer = useCallback(() => { if (retryTimerRef.current === null) return; window.clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }, []); const handleLoadError = useCallback(() => { if (retryAttempt >= 4 || retryTimerRef.current !== null) return; retryTimerRef.current = window.setTimeout( () => { retryTimerRef.current = null; setRetryAttempt((current) => Math.min(4, current + 1)); }, 750 * 2 ** retryAttempt, ); }, [retryAttempt]); return ( {alt} ); } 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 isVideoReferenceImageNode(node: CloneNode | null | undefined) { const mediaType = getDraftString(node, "mediaType") || node?.inputType; return Boolean( node && node.kind === "media" && mediaType === "image" && !isVideoEffectMaterialNode(node) && !isVideoRoleMaterialNode(node) && getNodeReferenceMediaUrl(node), ); } function getVideoReferenceModelIdentity(node: CloneNode | null | undefined) { const params = (node?.draftValues?.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; return `${String(settings.modelLabel || "")} ${String( params.model || settings.model || "", )}`.trim(); } function withVideoGenerationMode(node: CloneNode, mode: VideoGenerationMode): CloneNode { const params = (node.draftValues?.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; return { ...node, draftValues: { ...(node.draftValues || {}), params: { ...params, mode, settings: { ...settings, mode }, }, }, }; } 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 getVideoPromptMentions(node: CloneNode | null | undefined) { if (!node) return []; const params = getNodeParams(node); return normalizeVideoPromptMentions( node.draftValues?.promptMentions || params.promptMentions ); } function getVideoCanvasMotionMentions(node: CloneNode | null | undefined) { if (!node) return []; const params = getNodeParams(node); return normalizeVideoCanvasMotionMentions( node.draftValues?.motionMentions || params.motionMentions, ); } 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 || node?.draftValues?.durationSeconds || 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 getVideoNodePixelDimensions(node: CloneNode | null | undefined) { const outputLabel = getDraftString(node, "outputSizeLabel"); const match = outputLabel.match(/(\d+)\s*[×x]\s*(\d+)/i); const width = Number(match?.[1] || 1280); const height = Number(match?.[2] || 720); return { width: Number.isFinite(width) && width > 0 ? width : 1280, height: Number.isFinite(height) && height > 0 ? height : 720, }; } function formatVideoCropDimensions(width: number, height: number, separator: "x" | "×") { return `${Math.max(2, Math.round(width))} ${separator} ${Math.max(2, Math.round(height))}`; } 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.floor(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 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 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) || []; if (shotIdeas.length === 0) return []; return 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, ), ); } function createVideoStoryAnalysisNode({ framePreviews = [], position, sourceNode, rows, summary, }: { framePreviews?: string[]; position?: { x: number; y: number }; sourceNode: CloneNode; rows: VideoAnalysisRow[]; summary: string; }): CloneNode { const previewUrl = getVideoNodePreviewUrl(sourceNode) || getNodeMediaUrl(sourceNode) || ""; return { id: makeId("video-story"), kind: "video-story", title: "视频故事", subtitle: "视频解析结果", inputType: "text", x: position?.x ?? Math.round(sourceNode.x), y: position?.y ?? Math.round(sourceNode.y + sourceNode.height + 94), width: VIDEO_STORY_NODE_WIDTH, height: VIDEO_STORY_NODE_HEIGHT, accent: "#8b96ff", draftValues: { action: "VIDEO_STORY_ANALYSIS", framePreviews, generated: true, keyframeUrl: previewUrl, mediaType: "text", nodeType: "video-story", rows, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, status: "ready", summary, }, }; } function normalizeVideoHdResolutionOption(option: string) { const text = option.trim(); if (text.toUpperCase() === "2K") return "2K" as const; if (text.toUpperCase() === "4K") return "4K" as const; return "1080P" as const; } function createVideoHdUpscaleNode({ config, existingNode, option, sourceNode, }: { config: VideoCanvasFunctionConfig["videoHd"]; existingNode?: CloneNode; option: string; sourceNode: CloneNode; }): CloneNode { const resolution = normalizeVideoHdResolutionOption(option); const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || getNodeMediaUrl(sourceNode); const interpolationMode = ( getDraftString(existingNode, "interpolationMode") || "none" ) as VideoHdInterpolationMode; const slowMultiplier = getDraftNumber(existingNode, "slowMultiplier", 1); const resolutionConfig = config.resolutions[resolution]; return { id: existingNode?.id || makeId("video-hd"), kind: "model", title: `高清(${resolution})`, subtitle: "配置参数", inputType: "video", x: existingNode?.x ?? Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: existingNode?.y ?? Math.round(sourceNode.y), width: existingNode?.width ?? 350, height: existingNode?.height ?? 350, accent: "#8b96ff", draftValues: { ...(existingNode?.draftValues || {}), action: "VIDEO_HD_UPSCALE", cost: getVideoHdCost(config, resolution, interpolationMode, slowMultiplier), frameInterpolation: interpolationMode === "high-quality" ? "高质量补帧" : "不补帧", frameRate: getDraftNumber(existingNode, "frameRate", 30), generated: existingNode?.draftValues?.generated || false, hdEngine: config.model, hdResolution: resolution, interpolationMode, mediaType: "video", nodeType: "video-hd", outputSizeLabel: `${resolutionConfig.width} × ${resolutionConfig.height}`, scale: `${slowMultiplier}x`, slowMultiplier, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, sourceUrl, url: existingNode?.draftValues?.url || [], }, }; } type VideoHdSettingsPatch = Partial<{ frameRate: number; interpolationMode: VideoHdInterpolationMode; resolution: VideoHdResolution; slowMultiplier: number; }>; function updateVideoHdUpscaleNodeSettings( node: CloneNode, config: VideoCanvasFunctionConfig["videoHd"], patch: VideoHdSettingsPatch, ) { const resolution = patch.resolution || normalizeVideoHdResolutionOption( getDraftString(node, "hdResolution") || "1080P", ); const interpolationMode = patch.interpolationMode || ( getDraftString(node, "interpolationMode") || "none" ) as VideoHdInterpolationMode; const slowMultiplier = patch.slowMultiplier ?? getDraftNumber(node, "slowMultiplier", 1); const frameRate = patch.frameRate ?? getDraftNumber(node, "frameRate", 30); const resolutionConfig = config.resolutions[resolution]; return { ...node, title: `高清(${resolution})`, draftValues: { ...(node.draftValues || {}), cost: getVideoHdCost(config, resolution, interpolationMode, slowMultiplier), frameInterpolation: interpolationMode === "high-quality" ? "高质量补帧" : "不补帧", frameRate, hdEngine: config.model, hdResolution: resolution, interpolationMode, outputSizeLabel: `${resolutionConfig.width} × ${resolutionConfig.height}`, scale: `${slowMultiplier}x`, slowMultiplier, }, } satisfies CloneNode; } function createVideoCropResultNode({ cropRect, durationSeconds, mediaUrl, outputHeight, outputWidth, pathname, sourceNode, }: { cropRect: VideoCropRect; durationSeconds: number; mediaUrl: string; outputHeight: number; outputWidth: number; pathname: string; sourceNode: CloneNode; }): CloneNode { const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || getNodeMediaUrl(sourceNode); const previewUrl = getVideoNodePreviewUrl(sourceNode) || mediaUrl; 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), width, height, accent: "#8b96ff", draftValues: { action: "VIDEO_CROP", cropRect, durationSeconds, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl, nodeType: "video", outputSizeLabel: formatVideoCropDimensions(outputWidth, outputHeight, "×"), pathname, poster: previewUrl, sourceUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, uploadStatus: "complete", url: [mediaUrl], }, }; } function createVideoClipResultNode({ durationSeconds, mediaUrl, pathname, range, sourceNode, }: { durationSeconds: number; mediaUrl: string; pathname: string; range: VideoClipRange; sourceNode: CloneNode; }): CloneNode { const previewUrl = getVideoNodePreviewUrl(sourceNode) || mediaUrl; return { id: makeId("video"), kind: "media", title: "剪辑", subtitle: "生成结果", inputType: "video", x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y), width: sourceNode.width || 560, height: sourceNode.height || 315, accent: "#8b96ff", draftValues: { action: "VIDEO_CLIP", clipRange: range, durationSeconds, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl, nodeType: "video", outputSizeLabel: getDraftString(sourceNode, "outputSizeLabel") || "1280 × 720", pathname, poster: previewUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, sourceUrl: getNodeReferenceMediaUrl(sourceNode) || getNodeMediaUrl(sourceNode), uploadStatus: "complete", url: [mediaUrl], }, }; } function createVideoSubtitleEraseNode({ definition, existingIndex, mode = "smart", regions = [], sourceNode, subtitleEraseConfig, }: { definition: VideoCanvasFunctionDefinition; existingIndex: number; mode?: SubtitleEraseMode; regions?: SubtitleEraseRegion[]; sourceNode: CloneNode; subtitleEraseConfig: VideoCanvasFunctionConfig["videoSubtitleErase"]; }): 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((region) => { const normalized = normalizeSubtitleEraseRegion(region); return { height: Number(normalized.height.toFixed(4)), width: Number(normalized.width.toFixed(4)), x: Number(normalized.x.toFixed(4)), y: Number(normalized.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: Math.max(0, Math.round(definition.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: definition.model || subtitleEraseConfig.model, nodeType: "video", outputSizeLabel, poster: previewUrl, prompt: definition.prompt, 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: "音频参考" }]; } 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" || mode === "audio-to-video" ) { 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 === "4k") return "4k"; if (normalized === "1080p" || normalized === "1k") return "1080p"; return "720p"; } function formatVideoResolutionLabel(value: unknown) { const resolution = normalizeVideoResolutionValue(value); if (resolution === "4k") return "4K"; 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 === "audio-to-video") return "audio-to-video" as const; 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 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; originalLabel?: 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; parameterPricingCost?: number | null; videoCatalogBilling?: { cost: number; originalCost?: number } | null; videoCount?: number | 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 (params.modelKind === "video") { if (params.videoCatalogBilling && params.videoCatalogBilling.cost > 0) { const originalCost = params.videoCatalogBilling.originalCost; return { label: String(params.videoCatalogBilling.cost), message: originalCost ? `当前视频参数原价 ${originalCost} 积分,按模型折扣预计消耗 ${params.videoCatalogBilling.cost} 积分。` : `当前视频参数预计消耗 ${params.videoCatalogBilling.cost} 积分。`, originalLabel: originalCost ? String(originalCost) : undefined, }; } const modelPricing = matchedModel?.imageGenConfig; const hasGranularModelPricing = Boolean( modelPricing && ((modelPricing.creditCostPerVideoByDuration && Object.keys(modelPricing.creditCostPerVideoByDuration).length > 0) || (modelPricing.creditCostPerVideoByResolution && Object.keys(modelPricing.creditCostPerVideoByResolution).length > 0)), ); const videoCount = Math.max(1, Math.round(Number(params.videoCount || 1))); const rawModelUnitCost = modelPricing ? getGenerationCreditCostFromImageGenConfig( { ...modelPricing, creditDiscountRate: undefined }, "video", { duration: params.duration, resolution: params.resolution, }, ) : 0; const rawVideoCost = hasGranularModelPricing ? rawModelUnitCost * videoCount : Math.max(0, Math.round(Number(params.parameterPricingCost || 0))) || rawModelUnitCost * videoCount; const videoCost = modelPricing ? applyImageModelCreditDiscount(rawVideoCost, modelPricing) : rawVideoCost; if (videoCost > 0) { return { label: String(videoCost), message: rawVideoCost !== videoCost ? `当前视频参数原价 ${rawVideoCost} 积分,按模型折扣预计消耗 ${videoCost} 积分。` : `当前视频参数预计消耗 ${videoCost} 积分。`, originalLabel: rawVideoCost !== videoCost ? String(rawVideoCost) : undefined, }; } return fallback; } if (!matchedModel?.imageGenConfig) { 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)); } async function resolveGeneratedCanvasImageUrl( response: Awaited>, ) { const immediateUrl = getFirstGeneratedImageUrl(response); if (immediateUrl) return { success: true as const, url: immediateUrl }; const taskId = String(response.data?.taskId || "").trim(); if (!response.success || !taskId) { return { error: response.error || "生成图片失败", success: false as const, url: "" }; } for (let attempt = 0; attempt < 60; attempt += 1) { await wait(attempt < 8 ? 1500 : 2500); const taskResponse = await imageGenerationService.getImageTask(taskId); const task = taskResponse.data?.task; if (!taskResponse.success || !task) continue; if (task.status === "failed") { return { error: task.error || "生成图片失败", success: false as const, url: "" }; } if (task.status !== "succeeded") continue; const image = task.result?.images?.[0]; const url = image?.originalUrl || image?.thumbnailUrl || ""; return url ? { success: true as const, url } : { error: "生成任务未返回图片", success: false as const, url: "" }; } return { error: "生成任务等待超时", success: false as const, url: "" }; } 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 || "" : ""; } type StoredCanvasImage = { assetId?: string; pathname?: string; url: string; }; async function ensureStoredCanvasImage( sourceUrl: string, filenamePrefix: string, ): Promise { if (isPortableDirectorCaptureUrl(sourceUrl)) return { url: sourceUrl }; if (!sourceUrl || typeof window === "undefined") return null; const response = await fetch(sourceUrl); if (!response.ok) return null; 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); if (!upload.success || !upload.data?.url) return null; return { assetId: upload.data.assetId, pathname: upload.data.pathname, url: upload.data.url, }; } async function ensureRemoteCanvasImageUrl(sourceUrl: string, filenamePrefix: string) { return (await ensureStoredCanvasImage(sourceUrl, filenamePrefix))?.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; if (getNodeParams(node).functionKey === "audio-to-video") return "audio-to-video"; 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 { if (!node) return false; const mode = getPresetControlMode(node); return !!mode && (node.kind === "media" || node.kind === "script" || mode === "audio-to-video"); } function getTargetModuleKind(node: CloneNode): AddModuleKind | null { if (node.kind === "media") { const mediaType = getDraftString(node, "mediaType") || node.inputType; return mediaType === "video" ? "video" : null; } if (getPresetControlMode(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" || getPresetControlMode(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"); if (node.kind === "video-story" || action === "VIDEO_STORY_ANALYSIS") { return { ...node, kind: "video-story", title: "视频故事", subtitle: "视频解析结果", inputType: "text", width: VIDEO_STORY_NODE_WIDTH, height: VIDEO_STORY_NODE_HEIGHT, draftValues: { ...(node.draftValues || {}), action: "VIDEO_STORY_ANALYSIS", mediaType: "text", nodeType: "video-story", status: "ready", }, }; } 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 }, audioToVideoPrompt = "", ): { 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 right = createPresetMediaNode({ action: "VIDEO_GENERATE", generated: true, height: 430, mediaType: "video", preset, prompt: audioToVideoPrompt, title: "视频", width: 760, x: Math.round(center.x + 90), y: Math.round(center.y - 300), }); return { nodes: [audio, right], edges: makePresetEdges([audio], 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 assetManagerWidthRef = useRef(ASSET_MANAGER_DEFAULT_WIDTH); const dragRef = useRef(null); const keyboardPanPressedRef = useRef(false); 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, mutate: mutateMembershipSummary } = 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 localCanvasRevisionRef = useRef(0); const hydrationWriteDepthRef = useRef(0); const lastSavedCanvasSignatureRef = useRef(""); const nodeCreationOrdinalRef = useRef(1); const historyMediaOrdinalsRef = useRef({ audio: 1, image: 1, video: 1, }); const directorCaptureDeleteTimersRef = useRef>(new Map()); const [nodes, setNodesState] = useState([]); const [edges, setEdgesState] = useState([]); const setNodes: typeof setNodesState = useCallback((value) => { if (hydrationWriteDepthRef.current === 0) { localCanvasRevisionRef.current += 1; } setNodesState(value); }, []); const setEdges: typeof setEdgesState = useCallback((value) => { if (hydrationWriteDepthRef.current === 0) { localCanvasRevisionRef.current += 1; } setEdgesState(value); }, []); const [selectedNodeIds, setSelectedNodeIds] = useState([]); const selectedNodeId = selectedNodeIds[selectedNodeIds.length - 1] || null; const setSelectedNodeId = useCallback( (value: SetStateAction) => { setSelectedNodeIds((current) => { const currentNodeId = current[current.length - 1] || null; const nextNodeId = typeof value === "function" ? value(currentNodeId) : value; return nextNodeId ? [nextNodeId] : []; }); }, [], ); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [keyboardPanPressed, setKeyboardPanPressed] = useState(false); const [customerServiceOpen, setCustomerServiceOpen] = useState(false); 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 [subjectLibraryTargetNodeId, setSubjectLibraryTargetNodeId] = useState(null); const [videoRoleAgreementOpen, setVideoRoleAgreementOpen] = useState(false); const [videoRoleAgreementAccepted, setVideoRoleAgreementAccepted] = useState(false); const [videoRoleCompliantLibraryVisible, setVideoRoleCompliantLibraryVisible] = useState(false); const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); const [canvasViewMode, setCanvasViewMode] = useState("workflow"); const [storyboardAgentOpen, setStoryboardAgentOpen] = useState(false); const [storyboardAgentMounted, setStoryboardAgentMounted] = useState(false); const [storyboardExpandedPanel, setStoryboardExpandedPanel] = useState(null); const [storyboardVideoFilter, setStoryboardVideoFilter] = useState("all"); const [storyboardCompositionExport, setStoryboardCompositionExport] = useState<{ nodeId: string; progress: number } | null>(null); const [storyboardComposerReference, setStoryboardComposerReference] = useState(null); const storyboardReferenceSequenceRef = useRef(0); const [viewModeSavePending, setViewModeSavePending] = useState(false); const [projectTitle, setProjectTitle] = useState("未命名"); const [draftTitle, setDraftTitle] = useState("未命名"); const [activePanel, setActivePanel] = useState(null); const [assetManagerWidth, setAssetManagerWidth] = useState(ASSET_MANAGER_DEFAULT_WIDTH); const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false); const historyDownloadNodeRef = useRef(null); const [removeAiWatermark, setRemoveAiWatermark] = useState(false); const [watermarkRulesSaved, setWatermarkRulesSaved] = useState(false); const [videoPreviewNode, setVideoPreviewNode] = useState(null); const [isProjectLoading, setIsProjectLoading] = useState(true); const [isProjectPersistenceReady, setIsProjectPersistenceReady] = useState(false); 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); useEffect(() => { if (storyboardAgentOpen) setStoryboardAgentMounted(true); }, [storyboardAgentOpen]); useEffect(() => { setStoryboardExpandedPanel(null); setStoryboardVideoFilter("all"); setStoryboardCompositionExport(null); }, [canvasId]); const [snapEnabled, setSnapEnabled] = useState(false); const [arrangePreview, setArrangePreview] = useState | null>(null); const [arrangePending, setArrangePending] = useState(false); const [viewportTransitionMs, setViewportTransitionMs] = useState(0); const arrangeViewportTransitionTimerRef = useRef(null); const [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState(null); const [subtitleEraseMode, setSubtitleEraseMode] = useState("smart"); const [subtitleEraseRegions, setSubtitleEraseRegions] = useState>({}); const [subtitleEraseRegionHistory, setSubtitleEraseRegionHistory] = useState>({}); const [videoClipRanges, setVideoClipRanges] = useState>({}); const [videoClipProcessingIds, setVideoClipProcessingIds] = useState>({}); const [videoCropRects, setVideoCropRects] = useState>({}); const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); const [expandedVideoStoryNodeId, setExpandedVideoStoryNodeId] = useState(null); const [videoCropGenerateStates, setVideoCropGenerateStates] = useState>({}); const [videoAudioSplitStates, setVideoAudioSplitStates] = useState>({}); const [videoAudioTrackAvailability, setVideoAudioTrackAvailability] = useState>({}); const shortcutUndoStackRef = useRef([]); const shortcutRedoStackRef = useRef([]); const captureCanvasShortcutSnapshot = useCallback( (): CanvasShortcutSnapshot => structuredClone({ edges, nodes, selectedNodeIds, viewport, }), [edges, nodes, selectedNodeIds, viewport], ); const recordCanvasShortcutSnapshot = useCallback(() => { shortcutUndoStackRef.current = [ ...shortcutUndoStackRef.current, captureCanvasShortcutSnapshot(), ].slice(-100); shortcutRedoStackRef.current = []; }, [captureCanvasShortcutSnapshot]); const restoreCanvasShortcutSnapshot = useCallback( (snapshot: CanvasShortcutSnapshot) => { setNodes(snapshot.nodes); setEdges(snapshot.edges); setViewport(snapshot.viewport); setSelectedNodeIds(snapshot.selectedNodeIds); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setInspectorNodeId(null); setLineConnectState(null); }, [setEdges, setNodes], ); const undoCanvasShortcutMutation = useCallback(() => { const snapshot = shortcutUndoStackRef.current.pop(); if (!snapshot) return false; shortcutRedoStackRef.current = [ ...shortcutRedoStackRef.current, captureCanvasShortcutSnapshot(), ].slice(-100); restoreCanvasShortcutSnapshot(snapshot); return true; }, [captureCanvasShortcutSnapshot, restoreCanvasShortcutSnapshot]); const redoCanvasShortcutMutation = useCallback(() => { const snapshot = shortcutRedoStackRef.current.pop(); if (!snapshot) return false; shortcutUndoStackRef.current = [ ...shortcutUndoStackRef.current, captureCanvasShortcutSnapshot(), ].slice(-100); restoreCanvasShortcutSnapshot(snapshot); return true; }, [captureCanvasShortcutSnapshot, restoreCanvasShortcutSnapshot]); useEffect(() => () => assetManagerResizeCleanupRef.current?.(), []); useEffect(() => { let persistedWidth: string | null = null; try { persistedWidth = window.localStorage.getItem( WORKSPACE_PANEL_WIDTH_STORAGE_KEY, ); } catch { // Browser storage can be unavailable in private or restricted contexts. } const initialWidth = getInitialWorkspacePanelWidth( window.innerWidth, persistedWidth, ); assetManagerWidthRef.current = initialWidth; setAssetManagerWidth(initialWidth); const handleWindowResize = () => { const nextWidth = clampWorkspacePanelWidth( assetManagerWidthRef.current, window.innerWidth, ); assetManagerWidthRef.current = nextWidth; setAssetManagerWidth(nextWidth); }; window.addEventListener("resize", handleWindowResize); return () => window.removeEventListener("resize", handleWindowResize); }, []); useEffect( () => () => { if (arrangeViewportTransitionTimerRef.current !== null) { window.clearTimeout(arrangeViewportTransitionTimerRef.current); } }, [] ); useEffect(() => { try { setSnapEnabled(window.localStorage.getItem(CANVAS_GRID_SNAP_STORAGE_KEY) === "1"); } catch { setSnapEnabled(false); } }, []); const toggleCanvasGridSnap = useCallback(() => { setSnapEnabled((current) => { const next = !current; try { window.localStorage.setItem(CANVAS_GRID_SNAP_STORAGE_KEY, next ? "1" : "0"); } catch { // Keep the in-memory preference when browser storage is unavailable. } return next; }); }, []); useEffect(() => { try { setVideoRoleAgreementAccepted( window.localStorage.getItem(VIDEO_ROLE_AGREEMENT_STORAGE_KEY) === "1", ); } catch { setVideoRoleAgreementAccepted(false); } }, []); useEffect(() => { try { const saved = window.localStorage.getItem(VIDEO_WATERMARK_RULES_STORAGE_KEY); if (!saved) return; const parsed = JSON.parse(saved) as { removeWatermark?: boolean }; setRemoveAiWatermark(Boolean(parsed.removeWatermark)); setWatermarkRulesSaved(true); } catch { setWatermarkRulesSaved(false); } }, []); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, [nodes, selectedNodeId] ); const expandedVideoStoryNode = useMemo( () => nodes.find((node) => node.id === expandedVideoStoryNodeId) || null, [expandedVideoStoryNodeId, nodes], ); const selectedVideoMediaNode = useMemo( () => (isVideoMediaNode(selectedNode) && Boolean(getNodeMediaUrl(selectedNode)) ? selectedNode : null), [selectedNode] ); const selectedVideoAudioMediaUrl = selectedVideoMediaNode ? getNodeMediaUrl(selectedVideoMediaNode) || getNodeReferenceMediaUrl(selectedVideoMediaNode) : ""; const selectedVideoIsSilent = Boolean(selectedVideoMediaNode?.draftValues?.silentVideo); 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 directorProject = useMemo( () => normalizeVideoCanvasDirectorProject( directorConsoleNode?.draftValues?.directorProject, ), [directorConsoleNode?.draftValues?.directorProject], ); const directorPanoramaConnection = useMemo(() => { if (!directorConsoleNode) return null; for (const edge of edges) { if (edge.target !== directorConsoleNode.id) continue; const sourceNode = nodes.find((node) => node.id === edge.source); const mediaType = getDraftString(sourceNode, "mediaType") || sourceNode?.inputType; const imageUrl = getNodeReferenceMediaUrl(sourceNode); if (!sourceNode || mediaType !== "image" || !imageUrl) continue; return { edgeId: edge.id, fileName: sourceNode.title || "画布全景图.png", imageUrl, sourceNodeId: sourceNode.id, }; } return null; }, [directorConsoleNode, edges, nodes]); const directorPanoramaHistory = useMemo(() => { if (!directorConsoleNode) return []; const connectedSourceNodeIds = new Set( edges .filter((edge) => edge.target === directorConsoleNode.id) .map((edge) => edge.source), ); return nodes .slice() .reverse() .flatMap((candidate) => { const mediaType = getDraftString(candidate, "mediaType") || candidate.inputType; const imageUrl = getNodeReferenceMediaUrl(candidate); if (mediaType !== "image" || !imageUrl) return []; return [ { connected: connectedSourceNodeIds.has(candidate.id), fileName: candidate.title || "全景图", imageUrl, sourceNodeId: candidate.id, }, ]; }) .slice(0, videoCanvasFunctionConfig.directorDesk.panorama.historyLimit); }, [directorConsoleNode, edges, nodes, videoCanvasFunctionConfig.directorDesk.panorama.historyLimit]); const directorAiImportHistory = useMemo(() => { if (!directorConsoleNode) return []; return nodes .slice() .reverse() .flatMap((candidate) => { const mediaType = getDraftString(candidate, "mediaType") || candidate.inputType; const imageUrl = getNodeReferenceMediaUrl(candidate); if (mediaType !== "image" || !imageUrl) return []; return [{ createdAt: getDraftString(candidate, "aiImportCreatedAt") || new Date().toISOString(), fileName: candidate.title || "识图素材.png", imageUrl, sourceNodeId: candidate.id, }]; }) .slice(0, videoCanvasFunctionConfig.directorDesk.aiImport.historyLimit); }, [directorConsoleNode, nodes, videoCanvasFunctionConfig.directorDesk.aiImport.historyLimit]); 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(() => { if (!selectedVideoMediaNode) return; const nodeId = selectedVideoMediaNode.id; if (selectedVideoIsSilent) { setVideoAudioTrackAvailability((current) => ({ ...current, [nodeId]: "unavailable" })); return; } if (!selectedVideoAudioMediaUrl) return; let active = true; setVideoAudioTrackAvailability((current) => ({ ...current, [nodeId]: "checking" })); void inspectCanvasVideoAudioTrack(selectedVideoAudioMediaUrl).then((available) => { if (!active) return; setVideoAudioTrackAvailability((current) => ({ ...current, [nodeId]: available ? "available" : "unavailable", })); }); return () => { active = false; }; }, [selectedVideoAudioMediaUrl, selectedVideoIsSilent, selectedVideoMediaNode?.id]); const runProjectHydration = useCallback((apply: () => void) => { hydrationWriteDepthRef.current += 1; try { apply(); } finally { hydrationWriteDepthRef.current = Math.max(0, hydrationWriteDepthRef.current - 1); } }, []); useEffect(() => { let active = true; setIsProjectPersistenceReady(false); const hydrateProject = (project: { canvasData?: unknown; title?: string | null; }) => { const title = normalizeCanvasTitle(project.title); const restoredNodes = restoreNodes(project.canvasData); const restoredEdges = restoreEdges(project.canvasData); const restoredViewport = restoreViewport(project.canvasData); const restoredViewMode = resolveVideoCanvasViewMode(project.canvasData); const restoredSelectedNodeIds = restoreSelectedNodeIds( project.canvasData, restoredNodes, ); nodeCreationOrdinalRef.current = getInitialVideoCanvasNodeCreationOrdinal(restoredNodes); historyMediaOrdinalsRef.current = getInitialVideoCanvasHistoryMediaOrdinals(restoredNodes); runProjectHydration(() => { setProjectTitle(title); setDraftTitle(title); setNodes(restoredNodes); setEdges(restoredEdges); setSelectedNodeIds(restoredSelectedNodeIds); setViewport(restoredViewport); setCanvasViewMode(restoredViewMode); setStoryboardAgentOpen(restoredViewMode === "storyboard"); }); shortcutUndoStackRef.current = []; shortcutRedoStackRef.current = []; lastSavedCanvasSignatureRef.current = getVideoCanvasPersistenceSignature( restoredNodes, restoredEdges, restoredViewport, restoredViewMode, ); setIsProjectPersistenceReady(true); }; 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_CANVAS_VIEW_MODE_KEY]: "workflow", }) as CanvasState, "video" ); if (!seedResult.success) { console.warn("Failed to seed blank video canvas", seedResult.error); } else { lastSavedCanvasSignatureRef.current = getVideoCanvasPersistenceSignature( [], [], DEFAULT_VIEWPORT, "workflow", ); setIsProjectPersistenceReady(true); } } } catch (error) { console.warn("Failed to load video canvas project", error); } finally { setIsProjectLoading(false); } } void loadProject(); return () => { active = false; }; }, [canvasId, initialProject]); const buildPersistedVideoCanvasData = useCallback( (viewMode: VideoCanvasViewMode = canvasViewMode): CanvasState => ({ nodes: serializeNodes(nodes) as any, edges: serializeEdges(edges) as any, selectedNodeIds, viewport, tool: "select", [VIDEO_CANVAS_VIEW_MODE_KEY]: viewMode, }) as CanvasState, [canvasViewMode, edges, nodes, selectedNodeIds, viewport], ); const handleCanvasViewModeChange = useCallback( async (nextMode: VideoCanvasViewMode) => { if (nextMode === canvasViewMode) return true; if ( isProjectLoading || !isProjectPersistenceReady || viewModeSavePending ) { return false; } const previousMode = canvasViewMode; const previousAgentOpen = storyboardAgentOpen; setCanvasViewMode(nextMode); if (nextMode === "storyboard") setStoryboardAgentOpen(true); setActivePanel(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setLineConnectState(null); setViewModeSavePending(true); const result = await canvasService.saveCanvasState( canvasId, buildPersistedVideoCanvasData(nextMode), "video", ); if (!result.success) { setCanvasViewMode(previousMode); setStoryboardAgentOpen(previousAgentOpen); toast.error(result.error || "视图切换保存失败"); setViewModeSavePending(false); return false; } lastSavedCanvasSignatureRef.current = getVideoCanvasPersistenceSignature( nodes, edges, viewport, nextMode, ); setViewModeSavePending(false); return true; }, [ buildPersistedVideoCanvasData, canvasId, canvasViewMode, edges, isProjectLoading, isProjectPersistenceReady, nodes, storyboardAgentOpen, viewModeSavePending, viewport, ], ); const saveCanvas = useCallback(async () => { setIsSaving(true); const result = await canvasService.saveCanvasState( canvasId, buildPersistedVideoCanvasData(), "video" ); if (result.success) { lastSavedCanvasSignatureRef.current = getVideoCanvasPersistenceSignature( nodes, edges, viewport, canvasViewMode, ); toast.success("已保存"); } else { toast.error(result.error || "保存失败"); } setIsSaving(false); }, [buildPersistedVideoCanvasData, canvasId, canvasViewMode, edges, nodes, viewport]); const flushCanvasBeforeWorkspaceChange = useCallback(async () => { if (isProjectLoading || !isProjectPersistenceReady) return false; const signature = getVideoCanvasPersistenceSignature( nodes, edges, viewport, canvasViewMode, ); if (signature === lastSavedCanvasSignatureRef.current) return true; const result = await canvasService.saveCanvasState( canvasId, buildPersistedVideoCanvasData(), "video", ); if (!result.success) { toast.error(result.error || "当前画布保存失败"); return false; } lastSavedCanvasSignatureRef.current = signature; return true; }, [ buildPersistedVideoCanvasData, canvasId, canvasViewMode, edges, isProjectLoading, isProjectPersistenceReady, nodes, viewport, ]); useEffect(() => { if ( isProjectLoading || !isProjectPersistenceReady || viewModeSavePending ) { return; } const signature = getVideoCanvasPersistenceSignature( nodes, edges, viewport, canvasViewMode, ); if (signature === lastSavedCanvasSignatureRef.current) return; const timer = window.setTimeout(() => { void canvasService .saveCanvasState(canvasId, buildPersistedVideoCanvasData(), "video") .then((result) => { if (result.success) { lastSavedCanvasSignatureRef.current = signature; } else { console.warn("[VideoCanvas] Auto save failed", result.error); } }); }, 2500); return () => window.clearTimeout(timer); }, [buildPersistedVideoCanvasData, canvasId, canvasViewMode, edges, isProjectLoading, isProjectPersistenceReady, nodes, viewModeSavePending, viewport]); useEffect(() => { if (isProjectLoading || !isProjectPersistenceReady) return; const handlePageHide = () => { const signature = getVideoCanvasPersistenceSignature( nodes, edges, viewport, canvasViewMode, ); if (signature === lastSavedCanvasSignatureRef.current) return; void fetch(`/api/canvas/${canvasId}`, { body: JSON.stringify({ canvasData: buildPersistedVideoCanvasData(), workspaceType: "video", }), headers: { "Content-Type": "application/json" }, keepalive: true, method: "PUT", }); }; window.addEventListener("pagehide", handlePageHide); window.addEventListener("beforeunload", handlePageHide); return () => { window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("beforeunload", handlePageHide); }; }, [buildPersistedVideoCanvasData, canvasId, canvasViewMode, edges, isProjectLoading, isProjectPersistenceReady, nodes, viewport]); const commitTitle = useCallback(async () => { const previousTitle = projectTitle; const nextTitle = normalizeCanvasProjectTitle(draftTitle, previousTitle); setDraftTitle(nextTitle); if (nextTitle === previousTitle) return; setProjectTitle(nextTitle); const result = await canvasService.updateProject( canvasId, { title: nextTitle }, "video", ); if (!result.success) { setProjectTitle(previousTitle); setDraftTitle(previousTitle); toast.error(result.error || "项目名称保存失败"); } }, [canvasId, draftTitle, projectTitle]); const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => { if (nextNodes.length === 0 && nextEdges.length === 0) return; recordCanvasShortcutSnapshot(); setNodes((current) => [...current, ...nextNodes]); setEdges((current) => [...current, ...nextEdges]); setSelectedEdgeId(null); const last = nextNodes[nextNodes.length - 1]; if (last) setSelectedNodeId(last.id); }, [recordCanvasShortcutSnapshot]); const updateDirectorProject = useCallback( (directorNodeId: string, value: unknown) => { const project = normalizeVideoCanvasDirectorProject(value); if (!project) return; const restoredCaptureIds = new Set( collectVideoCanvasDirectorCaptures(project).map((capture) => capture.id), ); for (const [key, timer] of directorCaptureDeleteTimersRef.current) { const separatorIndex = key.indexOf(":"); const captureId = separatorIndex >= 0 ? key.slice(separatorIndex + 1) : key; if (!restoredCaptureIds.has(captureId)) continue; window.clearTimeout(timer); directorCaptureDeleteTimersRef.current.delete(key); } setNodes((current) => current.map((node) => node.id === directorNodeId ? { ...node, draftValues: { ...(node.draftValues || {}), directorProject: project, }, } : node, ), ); }, [setNodes], ); const persistDirectorCapture = useCallback( async ( directorNode: CloneNode, capture: StoryAiDirectorCreatedCapture, ): Promise => { const toastId = toast.loading("正在上传截图..."); const stored = await ensureStoredCanvasImage( capture.dataUrl, `director-desk-${directorNode.id}-${capture.id || Date.now()}`, ).catch(() => null); if (!stored) { toast.error("截图上传失败,请稍后重试", { id: toastId }); return { error: "截图上传失败", success: false }; } toast.success("截图上传完成", { id: toastId }); return { assetId: stored.assetId, cameraId: capture.cameraId, captureId: capture.id, imageUrl: stored.url, storagePathname: stored.pathname, success: true, }; }, [], ); const scheduleDirectorCaptureDeletion = useCallback( (directorNode: CloneNode, capture: StoryAiDirectorDeletedCapture) => { if (!capture.storagePathname && !capture.assetId) return; const key = `${directorNode.id}:${capture.id}`; const currentTimer = directorCaptureDeleteTimersRef.current.get(key); if (currentTimer) window.clearTimeout(currentTimer); const timer = window.setTimeout(() => { directorCaptureDeleteTimersRef.current.delete(key); void fetch("/api/video-canvas/director-captures", { body: JSON.stringify({ assetId: capture.assetId, canvasId, imageUrl: capture.imageUrl, pathname: capture.storagePathname, }), headers: { "Content-Type": "application/json" }, method: "DELETE", }); }, 30_000); directorCaptureDeleteTimersRef.current.set(key, timer); }, [canvasId], ); const sendDirectorSnapshotsToCanvas = useCallback( async (directorNode: CloneNode, snapshots: StoryAiDirectorCapture[]) => { if (snapshots.length === 0) return; const uploadedSnapshots = await Promise.all( snapshots.map(async (snapshot, index) => { const sourceUrl = String(snapshot.dataUrl || "").trim(); if (!sourceUrl) return null; const fallbackTitle = `机位截图${String(index + 1).padStart(2, "0")}`; const title = String(snapshot.title || snapshot.fileName || fallbackTitle) .trim() .replace(/\.[a-z0-9]+$/i, "") || fallbackTitle; const mediaUrl = await ensureRemoteCanvasImageUrl( sourceUrl, `director-desk-${directorNode.id}-${index + 1}`, ).catch(() => ""); if (!mediaUrl) return null; return { id: String(snapshot.id || makeId("director-shot")), mediaUrl, title, }; }), ); const usableSnapshots = uploadedSnapshots.filter( (snapshot): snapshot is NonNullable => Boolean(snapshot), ); if (usableSnapshots.length === 0) { toast.error("导演台截图上传失败,请稍后重试"); return; } const nextNodes = usableSnapshots.map((snapshot, index) => { const created = createMediaResourceNode({ action: "DIRECTOR_CAMERA_SNAPSHOT", height: DIRECTOR_CAMERA_SNAPSHOT_NODE_HEIGHT, mediaType: "image", mediaUrl: snapshot.mediaUrl, 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); toast.success(`${usableSnapshots.length} 张导演台截图已添加到画布`); }, [addNodes] ); const connectDirectorPanoramaSource = useCallback( (directorNode: CloneNode, sourceNode: CloneNode) => { const imageNodeIds = new Set( nodes .filter((candidate) => { const mediaType = getDraftString(candidate, "mediaType") || candidate.inputType; return mediaType === "image"; }) .map((candidate) => candidate.id), ); imageNodeIds.add(sourceNode.id); setNodes((current) => current.some((candidate) => candidate.id === sourceNode.id) ? current : [...current, sourceNode], ); setEdges((current) => [ ...current.filter( (edge) => edge.target !== directorNode.id || !imageNodeIds.has(edge.source), ), { id: makeId("edge"), source: sourceNode.id, target: directorNode.id, }, ]); setSelectedEdgeId(null); setSelectedNodeId(directorNode.id); }, [nodes], ); const uploadDirectorPanorama = useCallback( async ( directorNode: CloneNode, payload: StoryAiDirectorPanoramaRequest, ): Promise => { toast.info("正在上传全景图..."); const mediaUrl = await ensureRemoteCanvasImageUrl( payload.dataUrl, `director-panorama-${directorNode.id}`, ).catch(() => ""); if (!mediaUrl) { const error = "全景图上传失败,请稍后重试"; toast.error(error); return { error, success: false }; } const title = payload.fileName.replace(/\.[a-z0-9]+$/i, "").trim() || "全景图"; const panoramaNode = createMediaResourceNode({ action: "DIRECTOR_PANORAMA_UPLOAD", height: 280, mediaType: "image", mediaUrl, title, width: 420, x: Math.round(directorNode.x - 500), y: directorNode.y, }); panoramaNode.draftValues = { ...(panoramaNode.draftValues || {}), panorama: true, sourceDirectorNodeId: directorNode.id, }; connectDirectorPanoramaSource(directorNode, panoramaNode); toast.success("全景图已连接到导演台"); return { success: true }; }, [connectDirectorPanoramaSource], ); const uploadDirectorAiImportSource = useCallback( async ( directorNode: CloneNode, payload: StoryAiDirectorPanoramaRequest, ): Promise => { const mediaUrl = await ensureRemoteCanvasImageUrl( payload.dataUrl, `director-ai-import-${directorNode.id}`, ).catch(() => ""); if (!mediaUrl) { const error = "识图素材上传失败,请稍后重试"; toast.error(error); return { error, success: false }; } const title = payload.fileName.replace(/\.[a-z0-9]+$/i, "").trim() || "识图素材"; const createdAt = new Date().toISOString(); const sourceNode = createMediaResourceNode({ action: "DIRECTOR_AI_IMPORT_SOURCE", height: 280, mediaType: "image", mediaUrl, title, width: 420, x: Math.round(directorNode.x - 500), y: directorNode.y, }); sourceNode.draftValues = { ...(sourceNode.draftValues || {}), aiImportCreatedAt: createdAt, aiImportSource: true, sourceDirectorNodeId: directorNode.id, }; setNodes((current) => [...current, sourceNode]); setSelectedEdgeId(null); setSelectedNodeId(sourceNode.id); return { item: { createdAt, fileName: payload.fileName || "识图素材.png", imageUrl: mediaUrl, sourceNodeId: sourceNode.id, }, success: true, }; }, [], ); const generateDirectorAiImport = useCallback( async ( payload: StoryAiDirectorAiImportGenerateRequest, ): Promise => { const response = await fetch("/api/video-canvas/director-ai-import", { body: JSON.stringify({ imageUrl: payload.imageUrl, mode: payload.mode }), credentials: "same-origin", headers: { "content-type": "application/json" }, method: "POST", }); const body = await response.json().catch(() => ({})); if (!response.ok || body?.success !== true) { const insufficient = response.status === 402 || body?.code === "insufficient_points"; const error = insufficient ? "算力不足" : String(body?.error || "识图导入失败"); if (insufficient) setActivePanel("member"); toast.error(error); return { error, success: false }; } toast.success("站位参考已导入导演台"); return { result: body.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : {}, success: true, }; }, [], ); const generateDirectorPanorama = useCallback( async ( directorNode: CloneNode, payload: StoryAiDirectorPanoramaRequest, ): Promise => { const panoramaSettings = videoCanvasFunctionConfig.directorDesk.panorama; const definition = getConfiguredFunction( videoCanvasFunctionConfig, panoramaSettings.aiFunctionKey, ); if (!videoCanvasFunctionConfig.enabled || !definition?.enabled) { const error = "AI 全景图功能已在后台停用"; toast.error(error); return { error, success: false }; } const referenceImage = await ensureRemoteCanvasImageUrl( payload.dataUrl, `director-panorama-reference-${directorNode.id}`, ).catch(() => ""); if (!referenceImage) { const error = "参考图上传失败,请稍后重试"; toast.error(error); return { error, success: false }; } toast.info("AI 全景图已开始生成"); const response = await imageGenerationService.generateImage({ aspectRatio: panoramaSettings.aiAspectRatio, async: true, imageCount: 1, imageSize: panoramaSettings.aiImageSize, model: definition.model || videoCanvasFunctionConfig.defaultImageModel, prompt: definition.prompt, promptExtend: false, referenceImage, }); const generated = await resolveGeneratedCanvasImageUrl(response); if (!generated.success) { toast.error(generated.error); return generated; } const panoramaNode = createMediaResourceNode({ action: "DIRECTOR_PANORAMA_AI", height: 280, mediaType: "image", mediaUrl: generated.url, prompt: definition.prompt, title: `AI全景图-${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", })}`, width: 420, x: Math.round(directorNode.x - 500), y: directorNode.y, }); panoramaNode.draftValues = { ...(panoramaNode.draftValues || {}), generated: true, panorama: true, sourceDirectorNodeId: directorNode.id, }; connectDirectorPanoramaSource(directorNode, panoramaNode); toast.success("AI 全景图生成完成"); return { success: true }; }, [connectDirectorPanoramaSource, videoCanvasFunctionConfig], ); const useDirectorPanoramaHistoryItem = useCallback( async ( directorNode: CloneNode, sourceNodeId: string, ): Promise => { const sourceNode = nodes.find((candidate) => candidate.id === sourceNodeId); const mediaType = getDraftString(sourceNode, "mediaType") || sourceNode?.inputType; if (!sourceNode || mediaType !== "image" || !getNodeReferenceMediaUrl(sourceNode)) { return { error: "全景图历史记录已不可用", success: false }; } connectDirectorPanoramaSource(directorNode, sourceNode); toast.success("已从历史记录连接全景图"); return { success: true }; }, [connectDirectorPanoramaSource, nodes], ); 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: VideoCanvasToolboxTemplate) => { const title = workflow.name || "工具箱预设"; const toolboxConfig = videoCanvasFunctionConfig.toolbox; const { layout } = toolboxConfig; const { x: groupX, y: groupY } = getVideoCanvasToolboxGroupOrigin({ layout, viewport, viewportHeight: window.innerHeight, viewportWidth: window.innerWidth, }); const group = createToolboxTemplateGroupNode({ config: toolboxConfig, title, x: groupX, y: groupY, }); const referenceNode = createMediaResourceNode({ action: "IMAGE_RESOURCE", height: layout.reference.height, mediaType: "image", mediaUrl: workflow.referenceUrl || workflow.coverUrl || FIRST_FRAME_IMAGE_URL, title: "参考图", width: layout.reference.width, x: groupX + layout.reference.x, y: groupY + layout.reference.y, }); const reference: CloneNode = { ...referenceNode, draftValues: { ...(referenceNode.draftValues || {}), toolboxTemplate: true, }, }; const promptNode = createTextResourceNode({ content: workflow.prompt, height: layout.prompt.height, title: "提示词", width: layout.prompt.width, x: groupX + layout.prompt.x, y: groupY + layout.prompt.y, }); const prompt: CloneNode = { ...promptNode, draftValues: { ...(promptNode.draftValues || {}), toolboxTemplate: true, }, }; const resultNode = createMediaResourceNode({ action: "VIDEO_GENERATE", height: layout.result.height, mediaType: "video", mediaUrl: workflow.resultUrl || SCRIPT_VIDEO_REFERENCE_URL, prompt: workflow.prompt || `${title} 预设工作流`, title: "成品", width: layout.result.width, x: groupX + layout.result.x, y: groupY + layout.result.y, }); const result: CloneNode = { ...resultNode, draftValues: { ...(resultNode.draftValues || {}), toolboxTemplate: true, }, }; const presetNode: CloneNode = { ...result, subtitle: "工具箱工作流", draftValues: { ...(result.draftValues || {}), generated: true, params: { prompt: workflow.prompt || `${title} 预设工作流`, presetId: workflow.id, presetName: title, sourceNodeId: reference.id, templateCategory: workflow.categoryId, templateKey: workflow.id, }, sourceNodeId: reference.id, templateCategory: workflow.categoryId, templateKey: workflow.id, workflow, }, }; const groupedNodes = attachCanvasNodesToGroup( [group, reference, prompt, presetNode], group.id, [reference.id, prompt.id, presetNode.id], ); addNodes( groupedNodes, [ { 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 }, ] ); setSelectedNodeId(group.id); setSelectedEdgeId(null); setActivePanel(null); setConnectionMenu(null); }, [addNodes, videoCanvasFunctionConfig.toolbox, viewport] ); 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" && audioUrls.length === 0) { toast.error("音频生视频需要先连接音频素材"); return; } const requestedMode = normalizeVideoGenerationMode(getDraftString(node, "mode")) || (functionKey === "audio-to-video" ? "audio-to-video" : 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 referenceRule = resolveVideoReferenceSelectionRule( videoCanvasFunctionConfig.videoReferenceSelection, getVideoReferenceModelIdentity(node), ); const referenceImages = selectVideoReferenceImages( imageUrls, requestedMode, referenceRule.maxImages, ); 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" as const, 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, audioUrls, mode: requestedMode, prompt: finalPrompt, referenceImages, route, }, poster: previewUrl, prompt: finalPrompt, url: [], }, } : item ) ); try { const response = await videoGenerationService.generateVideo({ audioUrls, 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" as const, 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 ordinal = nodeCreationOrdinalRef.current; nodeCreationOrdinalRef.current += 1; const created = createTargetModuleNode(kind, 0, 0, ordinal, { nodeCreation: videoCanvasFunctionConfig.nodeCreation, videoModelCatalog: videoCanvasFunctionConfig.videoModelCatalog, }); 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, videoCanvasFunctionConfig] ); const addUploadedFilesAtPoint = useCallback( async (files: File[], center: { x: number; y: number }) => { if (files.length === 0) return; const uploadConfig = videoCanvasFunctionConfig.nodeCreation.upload; const limitedFiles = files.slice(0, uploadConfig.maxFiles); const acceptedFiles = limitedFiles.filter((file) => { const validation = validateVideoCanvasUploadedResource( file, uploadConfig, ); if (!validation.valid) toast.error(`${file.name}:${validation.error}`); return validation.valid; }); if (acceptedFiles.length === 0) return; if (files.length > uploadConfig.maxFiles) { toast.info(`单次最多上传 ${uploadConfig.maxFiles} 个文件`); } const toastId = toast.loading( acceptedFiles.length === 1 ? `正在上传 ${acceptedFiles[0].name}` : `正在上传 ${acceptedFiles.length} 个素材`, ); const results = await Promise.allSettled( acceptedFiles.map(async (file) => ({ file, upload: await uploadVideoCanvasResourceFile(file), })), ); const successful = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : [], ); const failures = results.filter((result) => result.status === "rejected"); if (successful.length === 0) { const firstError = failures[0]; toast.error( firstError?.status === "rejected" && firstError.reason instanceof Error ? firstError.reason.message : "上传失败", { id: toastId }, ); return; } const newNodes = successful.map(({ file, upload }, index) => { const created = createUploadedMediaNode( file, upload.mediaUrl, 0, 0, nodes.length + index + 1, upload.assetId, ); return { ...created, x: Math.round( center.x - created.width / 2 + index * uploadConfig.placementGap, ), y: Math.round(center.y - created.height / 2), }; }); addNodes(newNodes, []); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); toast.success( failures.length > 0 ? `已上传 ${newNodes.length} 个素材,${failures.length} 个失败` : newNodes.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`, { id: toastId }, ); }, [addNodes, nodes.length, videoCanvasFunctionConfig.nodeCreation.upload] ); 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 targetNodeId = effectLibraryTargetNodeId; const legacyEffectSourceIds = new Set( edges .filter((edge) => edge.target === targetNodeId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => isVideoEffectMaterialNode(node)) .map((node) => node.id), ); setNodes((current) => current .filter((node) => !legacyEffectSourceIds.has(node.id)) .map((node) => node.id === targetNodeId ? { ...node, draftValues: setVideoCanvasAttachedEffect( node.draftValues, preset, ), } : node, ), ); setEdges((current) => current.filter( (edge) => !( edge.target === targetNodeId && legacyEffectSourceIds.has(edge.source) ), ), ); setSelectedNodeId(effectLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); setEffectLibraryTargetNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); }, [edges, effectLibraryTargetNodeId, nodes], ); const applyVideoRolesFromLibrary = useCallback( ({ materialKind, roleId, }: { materialKind: VideoCanvasRoleMaterialKind; roleId: string; }) => { if (!roleLibraryTargetNodeId) { setActivePanel(null); return; } const preset = videoCanvasFunctionConfig.rolePresets.find( (item) => item.id === roleId, ); if (!preset) return; const legacyRoleNodeIds = new Set( edges .filter((edge) => edge.target === roleLibraryTargetNodeId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => isVideoRoleMaterialNode(node)) .map((node) => node.id), ); setNodes((current) => current .filter((node) => !legacyRoleNodeIds.has(node.id)) .map((node) => { if (node.id !== roleLibraryTargetNodeId) return node; const nextDraftValues = setVideoCanvasAttachedRole( node.draftValues, preset, materialKind, ); const params = (nextDraftValues.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; return { ...node, draftValues: { ...nextDraftValues, params: { ...params, mode: "first-frame", settings: { ...settings, mode: "first-frame" }, }, }, }; }), ); setEdges((current) => current.filter( (edge) => !(legacyRoleNodeIds.has(edge.source) && edge.target === roleLibraryTargetNodeId), ), ); setSelectedNodeId(roleLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); setRoleLibraryTargetNodeId(null); setVideoRoleAgreementOpen(false); setVideoRoleCompliantLibraryVisible(false); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); }, [ edges, nodes, roleLibraryTargetNodeId, videoCanvasFunctionConfig.rolePresets, ], ); const applyVideoSubjectFromLibrary = useCallback( (subject: ManagedCanvasSubject) => { if (!subjectLibraryTargetNodeId) { setActivePanel(null); return; } const attachedSubject = toVideoCanvasAttachedSubject(subject); if (!attachedSubject) { toast.error("主体数据不完整,请重新创建"); return; } const maximum = videoCanvasFunctionConfig.subjectLibrary.videoUsage.maxSubjects; const targetNode = nodes.find((node) => node.id === subjectLibraryTargetNodeId); const existingSubjects = getVideoCanvasAttachedSubjects( targetNode?.draftValues, maximum, ); if ( existingSubjects.length >= maximum && !existingSubjects.some((item) => item.id === attachedSubject.id) ) { toast.error(`最多可引用 ${maximum} 个主体`); return; } setNodes((current) => current.map((node) => node.id === subjectLibraryTargetNodeId ? { ...node, draftValues: addVideoCanvasAttachedSubject( node.draftValues, attachedSubject, maximum, ), } : node, ), ); setSelectedNodeId(subjectLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); setSubjectLibraryTargetNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); }, [ nodes, subjectLibraryTargetNodeId, videoCanvasFunctionConfig.subjectLibrary.videoUsage.maxSubjects, ], ); 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 pickerConfig = videoCanvasFunctionConfig.historyAssets.picker; 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 dimensions = getVideoCanvasHistoryMediaNodeDimensions({ height: getDraftNumber( sourceNode, "historyAssetHeight", sourceNode.height, ), mediaShortSide: pickerConfig.mediaShortSide, mediaType, width: getDraftNumber( sourceNode, "historyAssetWidth", sourceNode.width, ), }); const titleAllocation = allocateVideoCanvasHistoryMediaNodeTitle({ mediaType, nextOrdinal: historyMediaOrdinalsRef.current[mediaType], sourceTitle: sourceNode.title || "", }); historyMediaOrdinalsRef.current[mediaType] = titleAllocation.nextOrdinal; const position = getVideoCanvasHistoryMediaNodePosition({ center, index, placementGap: pickerConfig.placementGap, }); return createMediaResourceNode({ height: dimensions.height, mediaType, mediaUrl, title: titleAllocation.title, width: dimensions.width, x: position.x, y: position.y, }); }); if (createdNodes.length === 0) return; addNodes(createdNodes, []); setSelectedNodeId(null); setActivePanel(null); setCanvasCommand(null); setConnectionMenu(null); }, [ addNodes, toCanvasPoint, videoCanvasFunctionConfig.historyAssets.picker, ] ); 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 ordinal = nodeCreationOrdinalRef.current; nodeCreationOrdinalRef.current += 1; const created = createTargetModuleNode(kind, 0, 0, ordinal, { nodeCreation: videoCanvasFunctionConfig.nodeCreation, videoModelCatalog: videoCanvasFunctionConfig.videoModelCatalog, }); 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, videoCanvasFunctionConfig] ); 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 "打开导演台": { if (!videoCanvasFunctionConfig.directorDesk.enabled) { toast.info("3D导演台已在后台停用"); return; } 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" as const, 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" as const, 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 "音频生视频": { const audioSourceUrl = getNodeReferenceMediaUrl(anchor); if (!audioSourceUrl) return; anchorUpdates = { kind: "media", title: "音频", subtitle: "音频资源", width: 720, height: 190, inputType: "audio", }; draftUpdates = { action: "AUDIO_RESOURCE", generated: false, mediaType: "audio", mediaUrl: audioSourceUrl, nodeType: "audio", url: [audioSourceUrl], }; const video = { ...makeGenerated("video", "视频", Math.round(anchor.x + 720 + OFFICIAL_NODE_GAP), { params: { functionKey: "audio-to-video", mode: "audio-to-video", prompt: getConfiguredFunction(videoCanvasFunctionConfig, "audio-to-video").prompt, }, targetPreset: "audio-to-video", }), width: 760, height: 430, y: Math.round(anchor.y + 70), }; nextNodes.push(video); nextEdges.push({ id: makeId("edge"), source: anchor.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, videoCanvasFunctionConfig] ); 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(isVideoReferenceImageNode) .map((item) => resolveNodeReferenceImages(item)) ); const effectMaterials = incomingNodes.filter(isVideoEffectMaterialNode); const inlineEffect = getVideoCanvasAttachedEffect(node.draftValues); const effectDescriptors = inlineEffect ? [inlineEffect] : 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 inlineRole = getVideoCanvasAttachedRole(node.draftValues); const roleDescriptors = inlineRole ? [inlineRole] : 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"), imageUrl: getDraftString(item, "thumbnailUrl") || getNodeMediaUrl(item), })); const subjectDescriptors = getVideoCanvasAttachedSubjects( node.draftValues, videoCanvasFunctionConfig.subjectLibrary.videoUsage.maxSubjects, ); const params = (node.draftValues?.params || {}) as Record; const savedReferenceImages = Array.isArray(params.referenceImages) ? params.referenceImages.map(String) : []; const imageUrls = Array.from( new Set( [ ...imageReferenceLists.flat(), ...savedReferenceImages, ...getVideoCanvasMagicMarks(node).map((mark) => mark.imageUrl), ...roleDescriptors.map((role) => role.imageUrl), ] .map((item) => String(item || "").trim()) .filter(Boolean) ) ).slice(0, 12); 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 referenceRule = resolveVideoReferenceSelectionRule( videoCanvasFunctionConfig.videoReferenceSelection, `${requestedSettings?.modelLabel || paramsSettings.modelLabel || ""} ${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 smartStoryboardSupported = supportsVideoCanvasSmartStoryboard( videoCanvasFunctionConfig.smartStoryboard, `${requestedSettings?.modelLabel || paramsSettings.modelLabel || ""} ${preferredModelOverride}`, ); const smartStoryboard = getVideoCanvasSmartStoryboard(node.draftValues, { config: videoCanvasFunctionConfig.smartStoryboard, fallbackPrompt: String( payload?.prompt ?? params.prompt ?? node.draftValues?.prompt ?? "", ), fallbackTotalDurationSeconds: duration, }); const smartStoryboardValidation = validateVideoCanvasSmartStoryboard( smartStoryboard, ); if ( smartStoryboardSupported && smartStoryboard.enabled && !smartStoryboardValidation.valid ) { toast.error( smartStoryboardValidation.emptyShotIndexes.length > 0 ? "镜头描述未填写" : smartStoryboardValidation.status === "over" ? "分镜总时长超出" : "分镜总时长不足", ); return; } 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, referenceRule.maxImages, ); 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 motionMentions = normalizeVideoCanvasMotionMentions( payload?.motionMentions || getVideoCanvasMotionMentions(node), ); const markLabels = magicMarks.map( (mark) => mark.labelOptions[mark.selectedIndex] || mark.labelOptions[0] ); const markDefinition = getConfiguredFunction( videoCanvasFunctionConfig, "element-mark", ); const promptWithMarks = [ rawPrompt, composeVideoMarkGenerationPrompt({ labels: markLabels, template: markDefinition.generationPrompt, }), ] .filter(Boolean) .join(" "); const promptWithEffects = [ promptWithMarks, ...effectDescriptors.map((effect) => [ `应用视频特效「${effect.title}」。`, effect.prompt, ] .filter(Boolean) .join(" "), ), ] .filter(Boolean) .join(" "); const promptWithRoles = composeVideoCanvasRolePrompt( promptWithEffects, roleDescriptors, ); const promptWithMotions = composeVideoCanvasMotionPrompt( promptWithRoles, motionMentions, ); const finalPrompt = composeFunctionPrompt( definition, promptWithMotions || 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, videoCanvasFunctionConfig.videoAdvancedSettings.defaultAutoValidateMaterial, ), 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, videoCanvasFunctionConfig.videoAdvancedSettings.defaultSearchEnabled, ), 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: "", motionMentions, nodeType: "video", poster: previewUrl, params: { ...getNodeParams(item), duration, effects: effectDescriptors, roles: roleDescriptors, subjects: subjectDescriptors, mode: requestedMode, magicMarks, motionMentions, model: normalizeModelRef(preferredModelOverride) || undefined, prompt: rawPrompt || finalPrompt, ratio, referenceImages, resolution, settings: savedSettings, smartStoryboard, }, prompt: rawPrompt || finalPrompt, sourceUrl: previewUrl, url: [], }, } : item ) ); try { const response = await videoGenerationService.generateVideo({ autoValidateMaterial: savedSettings.autoValidateMaterial !== false, count: getVideoCanvasGenerationCount(savedSettings.count, 1), duration, generateAudio: savedSettings.audio !== "关闭", images: referenceImages, mode: requestedMode, modelLabel: savedSettings.modelLabel, preferredModelRef, prompt: finalPrompt, ratio, resolution, searchEnabled: savedSettings.searchEnabled !== false, subjects: subjectDescriptors, smartStoryboard: smartStoryboardSupported && smartStoryboard.enabled ? smartStoryboard : undefined, }); 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, motionMentions, nodeType: "video", outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`, params: { ...getNodeParams(item), duration, effects: effectDescriptors, roles: roleDescriptors, subjects: subjectDescriptors, mode: requestedMode, magicMarks, motionMentions, model: normalizeModelRef(preferredModelOverride) || undefined, prompt: rawPrompt || finalPrompt, ratio, referenceImages, resolution, settings: savedSettings, smartStoryboard, }, 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( async (nodeId: string, requestedRange?: VideoClipRange) => { const node = nodes.find((item) => item.id === nodeId); if (!node || videoClipProcessingIds[nodeId]) return; const mediaUrl = getNodeReferenceMediaUrl(node) || getNodeMediaUrl(node); if (!mediaUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } const existingRange = normalizeVideoClipRange( requestedRange || 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 sourceDuration = getVideoNodeDurationSeconds(node); setVideoClipProcessingIds((current) => ({ ...current, [nodeId]: true })); try { const processed = await processCanvasVideoClip(mediaUrl, normalizedRange, sourceDuration); const file = new File( [processed.blob], `${sanitizeDownloadFilename(node.title || "剪辑")}_clip.webm`, { type: processed.blob.type || "video/webm" }, ); const upload = await videoResourceService.uploadProcessedVideo(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "视频上传失败"); } const created = createVideoClipResultNode({ durationSeconds: processed.durationSeconds, mediaUrl: upload.data.url, pathname: upload.data.pathname, range: normalizedRange, sourceNode: node, }); setNodes((current) => [...current, created]); setSelectedNodeId(created.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); } catch (error) { toast.error(error instanceof Error ? error.message : "视频剪辑失败"); } finally { setVideoClipProcessingIds((current) => ({ ...current, [nodeId]: false })); } }, [nodes, videoClipProcessingIds, videoClipRanges] ); const processAudioResourceNode = useCallback( async (sourceNode, request) => { const mediaUrl = getNodeMediaUrl(sourceNode) || getNodeReferenceMediaUrl(sourceNode); if (!mediaUrl) throw new Error("当前音频没有可处理的素材地址"); const processed = await processCanvasAudio(mediaUrl, request); const title = getProcessedAudioTitle(sourceNode.title, request); const file = new File([processed.blob], `${sanitizeDownloadFilename(title)}.wav`, { type: "audio/wav", }); const upload = await audioResourceService.uploadProcessedAudio(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "处理后的音频上传失败"); } const created = createMediaResourceNode({ action: "AUDIO_RESOURCE", height: sourceNode.height, mediaType: "audio", mediaUrl: upload.data.url, title, width: sourceNode.width, x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: sourceNode.y, }); created.draftValues = { ...(created.draftValues || {}), assetId: upload.data.assetId, audioTool: request, durationSeconds: processed.durationSeconds, pathname: upload.data.pathname, sourceAudioNodeId: sourceNode.id, }; setNodes((current) => [...current, created]); setSelectedNodeId(sourceNode.id); }, [], ); const downloadMediaNode = useCallback(async (node: CloneNode) => { const mediaUrl = getNodeMediaUrl(node) || getNodeReferenceMediaUrl(node); if (!mediaUrl) { toast.error("当前资产没有可下载地址"); return; } const downloadUrl = mediaUrl; if (node.inputType === "audio" || node.inputType === "video") { try { const parsedUrl = new URL(mediaUrl, window.location.origin); const sourceUrl = parsedUrl.origin === window.location.origin ? parsedUrl.toString() : `/api/canvas/${node.inputType}-source?url=${encodeURIComponent(parsedUrl.toString())}`; const response = await fetch(sourceUrl, { credentials: "include" }); if (!response.ok) throw new Error("Media download failed"); const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = node.inputType === "video" ? getVideoDownloadFilename(node.title || "视频", mediaUrl, blob.type) : `${node.title || "未命名资产"}.${getAudioDownloadExtension(node.draftValues?.pathname || mediaUrl)}`; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000); if (node.inputType === "audio") toast.success("已开始下载当前资产"); return; } catch { toast.error(node.inputType === "video" ? "视频下载失败,请稍后重试" : "音频下载失败,请稍后重试"); return; } } const link = document.createElement("a"); link.href = downloadUrl; link.download = `${node.title || "未命名资产"}.png`; link.rel = "noreferrer"; document.body.appendChild(link); link.click(); link.remove(); toast.success("已开始下载当前资产"); }, []); const downloadVideoNode = useCallback((node: CloneNode) => { downloadMediaNode(node); }, [downloadMediaNode]); const requestVideoDownload = useCallback((node: CloneNode) => { if (node.inputType !== "video" || watermarkRulesSaved) { void downloadMediaNode(node); return; } historyDownloadNodeRef.current = node; setHistoryListDownloadPendingId(node.id); setHistoryWatermarkRulesOpen(true); }, [downloadMediaNode, watermarkRulesSaved]); const openHistoryWatermarkRules = useCallback((node: CloneNode) => { requestVideoDownload(node); }, [requestVideoDownload]); const saveHistoryWatermarkRules = useCallback(() => { try { window.localStorage.setItem( VIDEO_WATERMARK_RULES_STORAGE_KEY, JSON.stringify({ removeWatermark: removeAiWatermark }), ); } catch { // The preference still applies for the current canvas session. } const pendingNode = historyDownloadNodeRef.current || nodes.find((node) => node.id === historyListDownloadPendingId); setWatermarkRulesSaved(true); setHistoryWatermarkRulesOpen(false); setHistoryListDownloadPendingId(null); historyDownloadNodeRef.current = null; if (pendingNode) void downloadMediaNode(pendingNode); }, [downloadMediaNode, historyListDownloadPendingId, nodes, removeAiWatermark]); const extractVideoFrameNode = useCallback( async ( nodeId: string, kind: VideoFrameExtractionKind, currentTimeSeconds = 0, ) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode) return; const mediaUrl = getNodeMediaUrl(videoNode) || getNodeReferenceMediaUrl(videoNode); if (!mediaUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } try { const processed = await extractCanvasVideoFrame( mediaUrl, kind, currentTimeSeconds, getVideoNodeDurationSeconds(videoNode), ); const copy = getVideoFrameExtractionCopy(kind); const upload = await imageGenerationService.uploadImage(new File( [processed.blob], `${sanitizeDownloadFilename(videoNode.title || "视频")}-${copy.title}.png`, { type: "image/png" }, )); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "视频帧上传失败"); } const created = createMediaResourceNode({ action: "VIDEO_FRAME_EXTRACT", height: videoNode.height, mediaType: "image", mediaUrl: upload.data.url, title: copy.title, width: videoNode.width, x: Math.round(videoNode.x + videoNode.width + OFFICIAL_NODE_GAP), y: Math.round(videoNode.y), }); const frameNode: CloneNode = { ...created, subtitle: "视频帧截取", draftValues: { ...(created.draftValues || {}), alt: copy.alt, assetId: upload.data.assetId, frameExtractionKind: kind, frameTimeSeconds: processed.targetTimeSeconds, generated: true, generationProgress: 100, generationStatus: "complete", outputSizeLabel: `${processed.width} × ${processed.height}`, pathname: upload.data.pathname, sourceUrl: mediaUrl, sourceVideoNodeId: videoNode.id, sourceVideoTitle: videoNode.title, uploadStatus: "complete", }, }; addNodes( [frameNode], [{ id: makeId("edge"), source: videoNode.id, target: frameNode.id }], ); setSelectedNodeId(videoNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); } catch (error) { toast.error(error instanceof Error ? error.message : "视频帧截取失败"); } }, [addNodes, nodes] ); const createVideoHdUpscaleFromVideo = useCallback( (nodeId: string, option = "1080P") => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode) return; const hdNode = createVideoHdUpscaleNode({ config: videoCanvasFunctionConfig.videoHd, option, sourceNode: videoNode, }); addNodes([hdNode], [{ id: makeId("edge"), source: videoNode.id, target: hdNode.id }]); setSelectedNodeId(hdNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); return hdNode.id; }, [addNodes, nodes, videoCanvasFunctionConfig.videoHd] ); const updateVideoHdUpscaleSettings = useCallback( (nodeId: string, patch: VideoHdSettingsPatch) => { setNodes((current) => current.map((item) => item.id === nodeId ? updateVideoHdUpscaleNodeSettings(item, videoCanvasFunctionConfig.videoHd, patch) : item )); }, [videoCanvasFunctionConfig.videoHd], ); const generateVideoHdUpscaleNode = useCallback( async (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; const resolution = normalizeVideoHdResolutionOption( getDraftString(hdNode, "hdResolution") || "1080P", ); const interpolationMode = ( getDraftString(hdNode, "interpolationMode") || "none" ) as VideoHdInterpolationMode; const frameRate = interpolationMode === "high-quality" ? getDraftNumber(hdNode, "frameRate", 30) : 30; const slowMultiplier = getDraftNumber(hdNode, "slowMultiplier", 1); const dimensions = videoCanvasFunctionConfig.videoHd.resolutions[resolution]; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generated: false, generationProgress: 8, generationStatus: "pending", mediaUrl: "", url: [], }, } : item ) ); setSelectedNodeId(nodeId); try { const processed = await processCanvasVideoUpscale( mediaUrl, getVideoNodeDurationSeconds(sourceNode), dimensions.width, dimensions.height, frameRate, slowMultiplier, ); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generationProgress: 68, }, } : item ) ); const file = new File( [processed.blob], `${sanitizeDownloadFilename(hdNode.title || "高清视频")}.webm`, { type: processed.blob.type || "video/webm" }, ); const upload = await videoResourceService.uploadProcessedVideo(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "高清视频上传失败"); } const uploaded = upload.data; setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成结果", draftValues: { ...(item.draftValues || {}), durationSeconds: processed.durationSeconds, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: uploaded.url, pathname: uploaded.pathname, uploadStatus: "complete", url: [uploaded.url], }, } : item )); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generated: false, generationProgress: 0, generationStatus: "failed", }, } : item )); toast.error(error instanceof Error ? error.message : "视频高清处理失败"); } }, [nodes, videoCanvasFunctionConfig.videoHd] ); const updateVideoCropRect = useCallback((nodeId: string, nextRect: VideoCropRect) => { setVideoCropRects((current) => ({ ...current, [nodeId]: normalizeVideoCropRect(nextRect), })); }, []); const applyVideoCropSelection = useCallback( async (nodeId: string) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode || videoCropGenerateStates[nodeId]?.status === "loading") return; const sourceUrl = getNodeMediaUrl(videoNode) || getNodeReferenceMediaUrl(videoNode); if (!sourceUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } setVideoCropGenerateStates((current) => ({ ...current, [nodeId]: { progress: 37, status: "loading" }, })); try { const cropRect = normalizeVideoCropRect( videoCropRects[nodeId] || videoNode.draftValues?.cropRect || DEFAULT_VIDEO_CROP_RECT, ); const sourceDimensions = getVideoNodePixelDimensions(videoNode); const outputDimensions = getVideoCropOutputDimensions( cropRect, sourceDimensions.width, sourceDimensions.height, ); const processed = await processCanvasVideoCrop( sourceUrl, cropRect, getVideoNodeDurationSeconds(videoNode), outputDimensions.width, outputDimensions.height, ); setVideoCropGenerateStates((current) => ({ ...current, [nodeId]: { progress: 68, status: "loading" }, })); const file = new File( [processed.blob], `${sanitizeDownloadFilename(videoNode.title || "视频")}_crop.webm`, { type: processed.blob.type || "video/webm" }, ); const upload = await videoResourceService.uploadProcessedVideo(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "视频上传失败"); } const cropNode = createVideoCropResultNode({ cropRect, durationSeconds: processed.durationSeconds, mediaUrl: upload.data.url, outputHeight: outputDimensions.height, outputWidth: outputDimensions.width, pathname: upload.data.pathname, sourceNode: videoNode, }); setNodes((current) => [...current, cropNode]); setSelectedNodeId(cropNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); } catch (error) { toast.error(error instanceof Error ? error.message : "视频裁剪失败"); } finally { setVideoCropGenerateStates((current) => { const next = { ...current }; delete next[nodeId]; return next; }); } }, [nodes, videoCropGenerateStates, videoCropRects] ); 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( async (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 definition = videoCanvasFunctionConfig.functions["video-subtitle-erase"] || defaultVideoCanvasFunctionConfig.functions["video-subtitle-erase"]; if (!definition.enabled || !videoCanvasFunctionConfig.videoSubtitleErase.enabled) { toast.warning("智能去字幕功能已在后台关闭"); return; } const eraseNode = createVideoSubtitleEraseNode({ definition, existingIndex, mode, regions, sourceNode: videoNode, subtitleEraseConfig: videoCanvasFunctionConfig.videoSubtitleErase, }); setNodes((current) => [...current, eraseNode]); setEdges((current) => [...current, { id: makeId("edge"), source: videoNode.id, target: eraseNode.id }]); setSelectedNodeId(videoNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); try { const processed = await processCanvasVideoSubtitleErase( sourceUrl, mode, regions, getVideoNodeDurationSeconds(videoNode), videoCanvasFunctionConfig.videoSubtitleErase, ); setNodes((current) => current.map((item) => item.id === eraseNode.id ? { ...item, draftValues: { ...(item.draftValues || {}), eraseRatioLocation: processed.regions, erase_ratio_location: processed.regions, generationProgress: 68, }, } : item ) ); const file = new File( [processed.blob], `${sanitizeDownloadFilename(videoNode.title || "视频")}_subtitle-erased.webm`, { type: processed.blob.type || "video/webm" }, ); const upload = await videoResourceService.uploadProcessedVideo(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "视频上传失败"); } const uploaded = upload.data; setNodes((current) => current.map((item) => item.id === eraseNode.id ? { ...item, height: videoNode.height, subtitle: "去字幕结果", width: videoNode.width, draftValues: { ...(item.draftValues || {}), durationSeconds: processed.durationSeconds, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: uploaded.url, outputSizeLabel: `${processed.width} × ${processed.height}`, pathname: uploaded.pathname, uploadStatus: "complete", url: [uploaded.url], }, } : item ) ); } catch (error) { setNodes((current) => current.map((item) => item.id === eraseNode.id ? { ...item, subtitle: "去字幕失败", draftValues: { ...(item.draftValues || {}), generated: false, generationProgress: 0, generationStatus: "failed", }, } : item ) ); toast.error(error instanceof Error ? error.message : "视频去字幕失败"); } }, [nodes, subtitleEraseMode, subtitleEraseRegions, videoCanvasFunctionConfig] ); const splitVideoAudioTrack = useCallback( async (nodeId: string, mode: VideoAudioSplitMode) => { const videoNode = nodes.find((item) => item.id === nodeId); if (!videoNode || videoAudioSplitStates[nodeId]?.status === "loading") return; const definition = videoCanvasFunctionConfig.functions["video-audio-separation"] || defaultVideoCanvasFunctionConfig.functions["video-audio-separation"]; if (!definition.enabled || !videoCanvasFunctionConfig.videoAudioSeparation.enabled) { toast.warning("视频音频分离功能已在后台关闭"); return; } if (videoAudioTrackAvailability[nodeId] !== "available") { toast.warning("当前视频不包含可分离的内嵌音轨"); return; } const copy = getVideoAudioSplitResultCopy(mode); const baseTitle = videoNode.title || "视频"; const durationSeconds = getVideoNodeDurationSeconds(videoNode); const sourceUrl = getNodeMediaUrl(videoNode) || getNodeReferenceMediaUrl(videoNode); if (!sourceUrl) { toast.warning("视频尚未加载完成,请稍后再试"); return; } 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) ); const silentVideoX = rightX + 350 + OFFICIAL_NODE_GAP; setVideoAudioSplitStates((current) => ({ ...current, [nodeId]: { mode, status: "loading" }, })); setSelectedNodeId(videoNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); toast.info("已创建无声视频与音频节点并开始上传"); try { const processed = await processCanvasVideoAudioSplit( sourceUrl, mode, durationSeconds, videoCanvasFunctionConfig.videoAudioSeparation, ); const [audioUpload, videoUpload] = await Promise.all([ audioResourceService.uploadProcessedAudio(new File( [processed.audioBlob], `${sanitizeDownloadFilename(baseTitle)}_${copy.audioSuffix}.webm`, { type: processed.audioBlob.type || "audio/webm" }, )), videoResourceService.uploadProcessedVideo(new File( [processed.silentVideoBlob], `${sanitizeDownloadFilename(baseTitle)}_无声.webm`, { type: processed.silentVideoBlob.type || "video/webm" }, )), ]); if (!audioUpload.success || !audioUpload.data?.url) { throw new Error(audioUpload.error || "分离音频上传失败"); } if (!videoUpload.success || !videoUpload.data?.url) { throw new Error(videoUpload.error || "无声视频上传失败"); } const uploadedAudio = audioUpload.data; const uploadedVideo = videoUpload.data; const audioCreated = createMediaResourceNode({ action: mode === "voice" ? "VIDEO_VOICE_SPLIT" : mode === "background" ? "VIDEO_BACKGROUND_AUDIO_SPLIT" : "VIDEO_AUDIO_SPLIT", height: 148, mediaType: "audio", mediaUrl: uploadedAudio.url, title: `${baseTitle}_${copy.audioSuffix}`, width: 350, x: rightX, y: splitResultY, }); const audioNode: CloneNode = { ...audioCreated, subtitle: copy.audioSubtitle, draftValues: { ...(audioCreated.draftValues || {}), assetId: uploadedAudio.assetId, durationSeconds: processed.durationSeconds, generated: true, model: definition.model || videoCanvasFunctionConfig.videoAudioSeparation.model, pathname: uploadedAudio.pathname, separationApproximation: mode !== "audio-video", sourceVideoNodeId: videoNode.id, sourceVideoTitle: videoNode.title, splitMode: mode, }, }; const silentCreated = createMediaResourceNode({ action: mode === "voice" ? "VIDEO_VOICE_SPLIT_SILENT" : mode === "background" ? "VIDEO_BACKGROUND_AUDIO_SPLIT_SILENT" : "VIDEO_AUDIO_SPLIT_SILENT", height: 350, mediaType: "video", mediaUrl: uploadedVideo.url, title: `${baseTitle}_无声`, width: 350, x: silentVideoX, y: splitResultY, }); const silentVideoNode: CloneNode = { ...silentCreated, subtitle: "无声视频", draftValues: { ...(silentCreated.draftValues || {}), assetId: uploadedVideo.assetId, durationSeconds: processed.durationSeconds, generated: true, mediaType: "video", nodeType: "video", outputSizeLabel: `${processed.width} × ${processed.height}`, pathname: uploadedVideo.pathname, 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); } catch (error) { toast.error(error instanceof Error ? error.message : "视频音频分离失败"); } finally { setVideoAudioSplitStates((current) => { const next = { ...current }; delete next[nodeId]; return next; }); } }, [ addNodes, nodes, videoAudioSplitStates, videoAudioTrackAvailability, videoCanvasFunctionConfig, ] ); const addVideoStoryAnalysisNode = useCallback( ( sourceNode: CloneNode, rows: VideoAnalysisRow[], summary: string, options?: { framePreviews?: string[] } ) => { const existingStoryNodes = nodes.filter( (node) => node.kind === "video-story" || getDraftString(node, "action") === "VIDEO_STORY_ANALYSIS", ); const position = getVideoStoryPlacement(sourceNode, [sourceNode, ...existingStoryNodes]); const storyNode = createVideoStoryAnalysisNode({ framePreviews: options?.framePreviews, position, rows, sourceNode, summary, }); setNodes((current) => [...current, storyNode]); setSelectedNodeId(storyNode.id); setSelectedEdgeId(null); setActiveVideoToolbarMode(null); setViewport((current) => { const canvasWidth = canvasRef.current?.clientWidth || window.innerWidth; const canvasHeight = canvasRef.current?.clientHeight || window.innerHeight; return { ...current, x: Math.round(canvasWidth / 2 - (storyNode.x + storyNode.width / 2) * current.zoom), y: Math.round(canvasHeight / 2 - (storyNode.y + storyNode.height / 2) * current.zoom), }; }); 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; } const definition = getConfiguredFunction(videoCanvasFunctionConfig, "video-analysis"); const analysisCost = Math.max(0, Math.round(definition.cost || 0)); const prompt = composeFunctionPrompt( definition, [ "请解析当前视频,输出结构化视频故事表。", "每个镜头必须包含:镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词、关键帧建议。", "图像生成提示词和视频运动提示词优先使用英文,其他说明使用中文。", `视频标题:${node.title}`, ].join("\n") ); setActiveVideoToolbarMode(null); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { error: undefined, rows: [], status: "loading", summary: "正在解析视频故事...", }, })); if (analysisCost > 0 && !membershipSummary?.isHighVip) { void mutateMembershipSummary( (current) => current ? { ...current, summary: { ...(current.summary || {}), currentCredits: Math.max( 0, Number(current.summary?.currentCredits || 0) - analysisCost, ), }, } : current, { revalidate: false }, ); } try { const response = await workflowAnalysisService.analyze({ durationSeconds: getVideoNodeDurationSeconds(node), 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); if (rows.length === 0) { throw new Error("视频解析没有返回镜头数据"); } 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 ) ); addVideoStoryAnalysisNode(node, rows, summary, { framePreviews }); setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { rows, status: "ready", summary, }, })); void mutateMembershipSummary(); toast.success("解析完成,已创建视频故事节点"); } catch (error) { const errorMessage = error instanceof Error ? error.message : "视频解析失败"; setVideoAnalysisStates((current) => ({ ...current, [nodeId]: { error: errorMessage, rows: [], status: "error", summary: "视频解析失败", }, })); setSelectedNodeId(nodeId); setSelectedEdgeId(null); void mutateMembershipSummary(); toast.error(errorMessage); } }, [ addVideoStoryAnalysisNode, membershipSummary?.isHighVip, mutateMembershipSummary, nodes, videoAnalysisStates, videoCanvasFunctionConfig, ] ); const runPresetComposerSubmit = useCallback( async ( nodeId: string, mode: Exclude, draftPrompt: string, settings?: ComposerSettings, ) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; if (isOfficialImagePresetFunctionKey(mode)) return; const incomingNodes = edges .filter((edge) => edge.target === nodeId) .map((edge) => nodes.find((item) => item.id === edge.source)) .filter((item): item is CloneNode => Boolean(item)); const imageUrls = incomingNodes .filter((item) => item.inputType === "image") .map((item) => getNodeReferenceMediaUrl(item)) .filter(Boolean); const videoUrls = incomingNodes .filter((item) => item.inputType === "video") .map((item) => getNodeReferenceMediaUrl(item)) .filter(Boolean); 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 = mode === "audio-to-video" ? "audio-to-video" : "first-frame"; const referenceImages = selectVideoReferenceImages(imageUrls, requestedMode); if (mode === "audio-to-video" && audioUrls.length < 1) { toast.error("音频生视频需要先接入音频素材"); return; } if (mode !== "audio-to-video" && referenceImages.length < 1) { toast.error("首帧图生视频需要先接入首帧图片"); 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({ audioUrls, count: getVideoCanvasGenerationCount(savedSettings.count, 1), duration, generateAudio: savedSettings.audio !== "关闭", 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 ordinal = nodeCreationOrdinalRef.current; nodeCreationOrdinalRef.current += 1; const created = createTargetModuleNode(kind, 0, 0, ordinal, { nodeCreation: videoCanvasFunctionConfig.nodeCreation, videoModelCatalog: videoCanvasFunctionConfig.videoModelCatalog, }); 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, videoCanvasFunctionConfig] ); 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 createNodeDuplicatePayload = useCallback((nodeId: string, offsetY: number) => { const result = duplicateCanvasNodeTree({ createEdgeId: () => makeId("edge"), createNodeId: () => makeId("copy"), edges, nodes, offsetY, rootNodeId: nodeId, }); return result ? { duplicateEdges: result.duplicateEdges, duplicates: result.duplicateNodes, rootId: result.rootNodeId, } : null; }, [edges, nodes]); const createNodeSelectionDuplicatePayload = useCallback( (nodeIds: string[], includeInternalEdges: boolean) => { const result = duplicateCanvasNodeSelection({ createEdgeId: () => makeId("edge"), createNodeId: () => makeId("copy"), edges, includeInternalEdges, nodes, selectedNodeIds: nodeIds, }); return result ? { duplicateEdges: result.duplicateEdges, duplicates: result.duplicateNodes, rootIds: result.rootNodeIds, } : null; }, [edges, nodes], ); const duplicateNode = useCallback((nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return; const payload = createNodeDuplicatePayload(nodeId, node.height + 60); if (!payload) return; recordCanvasShortcutSnapshot(); const { duplicateEdges, duplicates, rootId } = payload; setNodes((current) => [...current, ...duplicates]); setEdges((current) => [...current, ...duplicateEdges]); setSelectedNodeIds(rootId ? [rootId] : []); setNodeContextMenu(null); setConnectionMenu(null); }, [createNodeDuplicatePayload, nodes, recordCanvasShortcutSnapshot]); 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 deleteNodes = useCallback((nodeIds: string[], options?: { silent?: boolean }) => { const existingNodeIds = new Set(nodes.map((node) => node.id)); const requestedIds = nodeIds.filter((nodeId) => existingNodeIds.has(nodeId)); if (requestedIds.length === 0) return; const groupIds = requestedIds.filter((nodeId) => isCanvasGroupNode(nodes.find((node) => node.id === nodeId)), ); const deletedIds = new Set([ ...requestedIds, ...collectCanvasGroupDescendantIds(nodes, groupIds), ]); recordCanvasShortcutSnapshot(); setNodes((current) => current .filter((node) => !deletedIds.has(node.id)) .map((node) => { const childNodeIds = getCanvasGroupChildIds(node); if (!childNodeIds.some((childId) => deletedIds.has(childId))) return node; return { ...node, draftValues: { ...(node.draftValues || {}), childNodeIds: childNodeIds.filter( (childId) => !deletedIds.has(childId), ), }, }; }), ); setEdges((current) => current.filter( (edge) => !deletedIds.has(edge.source) && !deletedIds.has(edge.target), ), ); setSelectedNodeIds((current) => current.filter((nodeId) => !deletedIds.has(nodeId)), ); setSelectedEdgeId(null); setInspectorNodeId((current) => current && deletedIds.has(current) ? null : current, ); setNodeContextMenu(null); setConnectionMenu(null); if (!options?.silent) { toast.success("节点已删除"); } }, [nodes, recordCanvasShortcutSnapshot]); const deleteNode = useCallback( (nodeId: string, options?: { silent?: boolean }) => { deleteNodes([nodeId], options); }, [deleteNodes], ); const groupSelectedNodes = useCallback(() => { const childNodes = nodes.filter( (node) => selectedNodeIds.includes(node.id) && !isCanvasGroupNode(node) && !getDraftString(node, "parentGroupId"), ); if (childNodes.length < 2) return false; const bounds = getCanvasGroupBounds(childNodes); if (!bounds) return false; const group: CloneNode = { ...bounds, accent: "#ffffff", draftValues: { action: "CANVAS_GROUP", nodeType: "group", }, id: makeId("group"), kind: "workflow", subtitle: "画布分组", title: "整组执行", }; recordCanvasShortcutSnapshot(); setNodes((current) => attachCanvasNodesToGroup( [...current, group], group.id, childNodes.map((node) => node.id), ), ); setSelectedNodeIds([group.id]); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); return true; }, [nodes, recordCanvasShortcutSnapshot, selectedNodeIds]); const mergeSelectedStoryboardGroups = useCallback(() => { const result = mergeCanvasStoryboardGroups({ createGroup: ({ bounds, childNodeIds }): CloneNode => ({ ...bounds, accent: "#ffffff", draftValues: { action: "CANVAS_GROUP", childNodeIds, nodeType: "group", }, id: makeId("group"), kind: "workflow", subtitle: "画布分组", title: `分组 ${childNodeIds.length} 个节点`, }), edges, nodes, selectedNodeIds, }); if (!result) return false; recordCanvasShortcutSnapshot(); setNodes(result.nodes); setEdges(result.edges); setSelectedNodeIds([result.group.id]); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); return true; }, [edges, nodes, recordCanvasShortcutSnapshot, selectedNodeIds]); const ungroupSelectedNodes = useCallback(() => { const groupIds = selectedNodeIds.filter((nodeId) => isCanvasGroupNode(nodes.find((node) => node.id === nodeId)), ); if (groupIds.length === 0) return false; const result = detachCanvasGroups(nodes, groupIds); recordCanvasShortcutSnapshot(); setNodes(result.nodes); setEdges((current) => current.filter( (edge) => !groupIds.includes(edge.source) && !groupIds.includes(edge.target), ), ); setSelectedNodeIds(result.detachedChildIds); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); return true; }, [nodes, recordCanvasShortcutSnapshot, selectedNodeIds]); 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, getConfiguredFunction(videoCanvasFunctionConfig, "audio-to-video").prompt, ); 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, videoCanvasFunctionConfig] ); 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 removeVideoReferenceFromTarget = useCallback( (sourceNodeId: string, targetNodeId: string) => { const targetNode = nodes.find((item) => item.id === targetNodeId); const rule = resolveVideoReferenceSelectionRule( videoCanvasFunctionConfig.videoReferenceSelection, getVideoReferenceModelIdentity(targetNode), ); const remainingReferenceCount = edges.filter((edge) => { if (edge.target !== targetNodeId || edge.source === sourceNodeId) return false; return isVideoReferenceImageNode( nodes.find((item) => item.id === edge.source), ); }).length; setEdges((current) => removeVideoReferenceEdge(current, sourceNodeId, targetNodeId), ); setNodes((current) => current.map((item) => { if (item.id !== targetNodeId) return item; const promptMentions = getVideoPromptMentions(item).filter( (mention) => mention.sourceNodeId !== sourceNodeId, ); const nextNode = withVideoGenerationMode( item, remainingReferenceCount > 0 ? rule.mode : getVideoCanvasAttachedRole(item.draftValues) ? "first-frame" : "text-to-video", ); return { ...nextNode, draftValues: { ...(nextNode.draftValues || {}), promptMentions, params: { ...((nextNode.draftValues?.params as | Record | undefined) || {}), promptMentions, }, }, }; }), ); setSelectedNodeId(targetNodeId); setSelectedEdgeId(null); }, [edges, nodes, videoCanvasFunctionConfig.videoReferenceSelection], ); const toggleVideoReferenceForTarget = useCallback( (sourceNodeId: string, targetNodeId: string) => { const targetNode = nodes.find((item) => item.id === targetNodeId); if (!targetNode) return; const rule = resolveVideoReferenceSelectionRule( videoCanvasFunctionConfig.videoReferenceSelection, getVideoReferenceModelIdentity(targetNode), ); const referenceEdges = edges.filter((edge) => { if (edge.target !== targetNodeId) return false; return isVideoReferenceImageNode( nodes.find((item) => item.id === edge.source), ); }); const action = resolveVideoReferenceToggleAction( referenceEdges, sourceNodeId, targetNodeId, rule.maxImages, ); if (action === "remove") { removeVideoReferenceFromTarget(sourceNodeId, targetNodeId); return; } if (action === "limit") return; setEdges((current) => toggleVideoReferenceEdge(current, sourceNodeId, targetNodeId, () => ({ id: makeId("edge"), source: sourceNodeId, target: targetNodeId, })), ); setNodes((current) => current.map((item) => item.id === targetNodeId ? withVideoGenerationMode(item, rule.mode) : item, ), ); if (rule.autoExit) setReferenceSelectionTargetId(null); setSelectedNodeId(targetNodeId); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); }, [ edges, nodes, removeVideoReferenceFromTarget, videoCanvasFunctionConfig.videoReferenceSelection, ], ); const startNodeDrag = useCallback( (event: ReactPointerEvent, node: CloneNode) => { event.stopPropagation(); if ( shouldStartCanvasPointerPan({ button: event.button, keyboardPanPressed: keyboardPanPressedRef.current, }) ) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { kind: "canvas", startX: event.clientX, startY: event.clientY, viewportX: viewport.x, viewportY: viewport.y, }; return; } 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(node.id); 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 eligible = node.id !== referenceSelectionTargetId && isVideoReferenceImageNode(node); if (!eligible) return; toggleVideoReferenceForTarget(node.id, referenceSelectionTargetId); 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); let dragNodesSource = nodes; let dragNodeIds: string[]; if (event.altKey) { const sourceNodeIds = selectedNodeIds.includes(node.id) ? selectedNodeIds : [node.id]; const payload = createNodeSelectionDuplicatePayload( sourceNodeIds, event.metaKey || event.ctrlKey, ); if (!payload) return; recordCanvasShortcutSnapshot(); setNodes((current) => [...current, ...payload.duplicates]); setEdges((current) => [...current, ...payload.duplicateEdges]); setSelectedNodeIds(payload.rootIds.length === 1 ? payload.rootIds : []); dragNodeIds = payload.duplicates.map((item) => item.id); dragNodesSource = payload.duplicates; } else { const additiveSelection = event.metaKey || event.ctrlKey || event.shiftKey; const nextSelectedNodeIds = additiveSelection ? selectedNodeIds.includes(node.id) ? selectedNodeIds.filter((nodeId) => nodeId !== node.id) : [...selectedNodeIds, node.id] : selectedNodeIds.includes(node.id) ? selectedNodeIds : [node.id]; setSelectedNodeIds(nextSelectedNodeIds); if (!nextSelectedNodeIds.includes(node.id)) return; dragNodeIds = expandCanvasGroupSelection(nodes, nextSelectedNodeIds); } setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); const dragNodes = dragNodesSource .filter((item) => dragNodeIds.includes(item.id)) .map((item) => ({ id: item.id, x: item.x, y: item.y })); dragRef.current = { kind: "node", nodes: dragNodes, startX: event.clientX, startY: event.clientY, }; }, [ analyzeVideoCanvasMark, connectExistingNodes, createNodeSelectionDuplicatePayload, getCanvasPoint, lineConnectState, markAnalysisState, markSelectionTargetId, recordCanvasShortcutSnapshot, referenceSelectionTargetId, selectedNodeIds, setEdges, setNodes, toggleVideoReferenceForTarget, viewport.x, viewport.y, ] ); const startCanvasDrag = useCallback((event: ReactPointerEvent) => { if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; const startPan = shouldStartCanvasPointerPan({ button: event.button, keyboardPanPressed: keyboardPanPressedRef.current, }); if (!startPan && event.button !== 0) return; if (!startPan) { setSelectedNodeId(null); setSelectedEdgeId(null); setInspectorNodeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setLineConnectState(null); return; } event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { kind: "canvas", startX: event.clientX, startY: event.clientY, viewportX: viewport.x, viewportY: viewport.y, }; }, [viewport.x, viewport.y]); useEffect(() => { const releaseKeyboardPan = () => { keyboardPanPressedRef.current = false; setKeyboardPanPressed(false); }; const onKeyDown = (event: KeyboardEvent) => { if (!isCanvasKeyboardPanKey(event.code)) return; const target = event.target as HTMLElement | null; if (target?.closest("input, textarea, select, [contenteditable='true']")) { return; } event.preventDefault(); keyboardPanPressedRef.current = true; setKeyboardPanPressed(true); }; const onKeyUp = (event: KeyboardEvent) => { if (isCanvasKeyboardPanKey(event.code)) releaseKeyboardPan(); }; window.addEventListener("blur", releaseKeyboardPan); window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); return () => { keyboardPanPressedRef.current = false; window.removeEventListener("blur", releaseKeyboardPan); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); }; }, []); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.closest("input, textarea, select, [contenteditable='true']")) return; const commandPressed = event.metaKey || event.ctrlKey; const key = event.key.toLowerCase(); if (commandPressed && !event.altKey && key === "z") { event.preventDefault(); if (event.shiftKey) redoCanvasShortcutMutation(); else undoCanvasShortcutMutation(); return; } if (commandPressed && event.altKey && !event.shiftKey && key === "g") { event.preventDefault(); mergeSelectedStoryboardGroups(); return; } if (commandPressed && !event.altKey && event.shiftKey && key === "g") { event.preventDefault(); ungroupSelectedNodes(); return; } if (commandPressed && !event.altKey && !event.shiftKey && key === "g") { event.preventDefault(); groupSelectedNodes(); return; } if ( commandPressed && !event.altKey && !event.shiftKey && key === "d" && selectedNodeId ) { event.preventDefault(); duplicateNode(selectedNodeId); return; } if ( commandPressed && !event.altKey && event.key === "Enter" && selectedNodeId ) { event.preventDefault(); void runNode(selectedNodeId); return; } if (commandPressed && !event.altKey && !event.shiftKey && key === "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 ( !commandPressed && !event.altKey && !event.shiftKey && event.key === "Tab" ) { event.preventDefault(); if (!canvasCommand) { const screenX = Math.round(window.innerWidth / 2); const screenY = Math.round(window.innerHeight / 2); const point = getCanvasPoint(screenX, screenY); setSelectedNodeId(null); setSelectedEdgeId(null); setInspectorNodeId(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel(null); setLineConnectState(null); setCanvasCommand({ canvasX: point.x, canvasY: point.y, screenX, screenY, }); } return; } if ((event.key === "Delete" || event.key === "Backspace") && selectedEdgeId) { event.preventDefault(); recordCanvasShortcutSnapshot(); deleteSelectedEdge(); return; } if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeIds.length > 0) { event.preventDefault(); deleteNodes(selectedNodeIds, { silent: true }); return; } if (event.key === "Escape") { if (activePanel === "shortcuts" || activePanel === "tutorial") return; 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); }, [ activePanel, canvasCommand, createLineConnectState, deleteNodes, deleteSelectedEdge, duplicateNode, getCanvasPoint, groupSelectedNodes, mergeSelectedStoryboardGroups, nodes, recordCanvasShortcutSnapshot, redoCanvasShortcutMutation, runNode, selectedEdgeId, selectedNodeId, selectedNodeIds, ungroupSelectedNodes, undoCanvasShortcutMutation, ]); 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 anchor = drag.nodes[0]; if (!anchor) return; const { x: nextX, y: nextY } = resolveCanvasGridSnapPosition({ deltaX: dx, deltaY: dy, enabled: snapEnabled, startX: anchor.x, startY: anchor.y, }); const snappedDeltaX = nextX - anchor.x; const snappedDeltaY = nextY - anchor.y; const startByNodeId = new Map( drag.nodes.map((item) => [item.id, item]), ); setNodes((current) => current.map((node) => { const start = startByNodeId.get(node.id); return start ? { ...node, x: start.x + snappedDeltaX, y: start.y + snappedDeltaY, } : 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((direction: CanvasZoomDirection) => { setViewport((current) => { const nextZoom = stepCanvasZoom(current.zoom, direction); 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 fitArrangedNodesToView = useCallback((targetNodes: CloneNode[]) => { window.requestAnimationFrame(() => { const nextViewport = getArrangeCanvasViewport({ canvasHeight: canvasRef.current?.clientHeight || window.innerHeight, canvasWidth: canvasRef.current?.clientWidth || window.innerWidth, maxZoom: CANVAS_MAX_ZOOM, minZoom: CANVAS_MIN_ZOOM, nodes: targetNodes, }); if (!nextViewport) return; if (arrangeViewportTransitionTimerRef.current !== null) { window.clearTimeout(arrangeViewportTransitionTimerRef.current); } setViewportTransitionMs(300); setViewport(nextViewport); arrangeViewportTransitionTimerRef.current = window.setTimeout(() => { setViewportTransitionMs(0); arrangeViewportTransitionTimerRef.current = null; }, 300); }); }, []); useEffect(() => { const onZoomShortcut = (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) return; if (event.key === "+" || event.key === "=") { event.preventDefault(); zoomBy("in"); return; } if (event.key === "-") { event.preventDefault(); zoomBy("out"); return; } if (event.key === "0") { event.preventDefault(); fitDockTarget(); } }; window.addEventListener("keydown", onZoomShortcut); return () => window.removeEventListener("keydown", onZoomShortcut); }, [fitDockTarget, zoomBy]); const handleArrangeCanvas = useCallback(async () => { setDockOverviewOpen(false); if (arrangePending || arrangePreview) return; setArrangePending(true); try { const updates = await arrangeCanvasNodes(nodes, edges); if (updates.length === 0) { fitArrangedNodesToView(nodes); return; } const nodesById = new Map(nodes.map((node) => [node.id, node])); const originalPositions: Record = {}; for (const update of updates) { const node = nodesById.get(update.id); if (node) originalPositions[node.id] = { x: node.x, y: node.y }; } const arrangedNodes = applyCanvasArrangeUpdates(nodes, updates); setNodes(arrangedNodes); setArrangePreview(originalPositions); fitArrangedNodesToView(arrangedNodes); } finally { setArrangePending(false); } }, [arrangePending, arrangePreview, edges, fitArrangedNodesToView, nodes]); const restoreArrangePreview = useCallback(() => { if (!arrangePreview) return; const restoredNodes = nodes.map((node) => { const position = arrangePreview[node.id]; return position ? { ...node, ...position } : node; }); setNodes(restoredNodes); setArrangePreview(null); fitArrangedNodesToView(restoredNodes); }, [arrangePreview, fitArrangedNodesToView, nodes]); const keepArrangePreview = useCallback(() => { setArrangePreview(null); }, []); useEffect(() => { if (arrangePreview || arrangePending || markSelectionTargetId) return; const onArrangeShortcut = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (event.repeat || target?.closest("input, textarea, select, [contenteditable='true']")) { return; } if ( !event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey || event.code !== "KeyF" ) { return; } event.preventDefault(); event.stopPropagation(); void handleArrangeCanvas(); }; window.addEventListener("keydown", onArrangeShortcut, true); return () => window.removeEventListener("keydown", onArrangeShortcut, true); }, [arrangePending, arrangePreview, handleArrangeCanvas, markSelectionTargetId]); 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 updateWidth = (clientX: number) => { const nextWidth = clampWorkspacePanelWidth( clientX, window.innerWidth, ); assetManagerWidthRef.current = nextWidth; setAssetManagerWidth(nextWidth); }; const handlePointerMove = (moveEvent: PointerEvent) => { updateWidth(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; try { window.localStorage.setItem( WORKSPACE_PANEL_WIDTH_STORAGE_KEY, String(assetManagerWidthRef.current), ); } catch { // Keep the resized width for this session when storage is unavailable. } }; 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; updateWidth(event.clientX); }, [] ); 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 openStoryboardNode = useCallback( async (nodeId: string) => { const switched = await handleCanvasViewModeChange("workflow"); if (!switched) return; window.requestAnimationFrame(() => locateCanvasNode(nodeId)); }, [handleCanvasViewModeChange, locateCanvasNode], ); const activateStoryboardReference = useCallback( (nodeId: string) => { if (canvasViewMode !== "workflow") return; locateCanvasNode(nodeId); }, [canvasViewMode, locateCanvasNode], ); const updateStoryboardComposition = useCallback( ( nodeId: string, project: VideoCanvasStoryboardCompositionProject, ) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, draftValues: { ...(node.draftValues || {}), storyboardComposition: project, }, } : node, ), ); }, [setNodes], ); const exportStoryboardComposition = useCallback( async ( nodeId: string, project: VideoCanvasStoryboardCompositionProject, destination: "canvas" | "local", ) => { if (storyboardCompositionExport) return; const sourceNode = nodes.find((node) => node.id === nodeId); if (!sourceNode) return; if (!project.clips.length) { toast.warning("请先添加视频片段"); return; } setStoryboardCompositionExport({ nodeId, progress: 0 }); try { const rendered = await renderVideoCanvasStoryboardComposition( project, (progress) => setStoryboardCompositionExport({ nodeId, progress }), ); const filename = `${sanitizeDownloadFilename(sourceNode.title || "视频合成")}.webm`; const file = new File([rendered.blob], filename, { type: rendered.blob.type || "video/webm", }); if (destination === "local") { const objectUrl = URL.createObjectURL(file); const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000); toast.success("成片已导出到本地"); return; } const upload = await videoResourceService.uploadProcessedVideo(file); if (!upload.success || !upload.data?.url) { throw new Error(upload.error || "成片上传失败"); } const width = Math.max(320, Math.min(560, rendered.width)); const height = Math.max( 180, Math.round(width * (rendered.height / rendered.width)), ); const resultNode: CloneNode = { accent: "#8b96ff", draftValues: { action: "VIDEO_EDITOR_EXPORT", compositionSnapshot: project, durationSeconds: rendered.durationSeconds, generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "video", mediaUrl: upload.data.url, nodeType: "video", originalHeight: rendered.height, originalWidth: rendered.width, outputSizeLabel: `${rendered.width} × ${rendered.height}`, pathname: upload.data.pathname, sourceCompositionNodeId: sourceNode.id, sourceCompositionTitle: sourceNode.title, storyboardVideoCategory: "film", uploadStatus: "complete", url: [upload.data.url], }, height, id: makeId("video"), inputType: "video", kind: "media", subtitle: "视频合成", title: "成品", width, x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y), }; recordCanvasShortcutSnapshot(); setNodes((current) => [...current, resultNode]); toast.success("成片已导出到画布"); } catch (error) { toast.error(error instanceof Error ? error.message : "成片导出失败"); } finally { setStoryboardCompositionExport(null); } }, [ nodes, recordCanvasShortcutSnapshot, setNodes, storyboardCompositionExport, ], ); const setStoryboardElementCategory = useCallback( ( nodeId: string, category: VideoCanvasKeyElementCategory, ): string | null => { const source = nodes.find((node) => node.id === nodeId); if (!source) return null; const sourceRelationId = typeof source.draftValues?.storyboardKeyElementSourceId === "string" ? source.draftValues.storyboardKeyElementSourceId : source.id; const derived = source.draftValues?.storyboardDerivedKeyElement === true ? source : nodes.find( (node) => node.draftValues?.storyboardDerivedKeyElement === true && node.draftValues?.storyboardKeyElementSourceId === sourceRelationId, ); const targetId = derived?.id || makeId("key-element"); const categoryLabel = getVideoCanvasKeyElementCategoryLabel(category); recordCanvasShortcutSnapshot(); setNodes((current) => { if (derived) { return current.map((node) => node.id === derived.id ? { ...node, draftValues: { ...(node.draftValues || {}), elementType: categoryLabel, keyElement: true, keyElementType: categoryLabel, storyboardCategory: category, storyboardDerivedKeyElement: true, storyboardKeyElementSourceId: sourceRelationId, }, } : node, ); } const relationDraftValues = { ...(source.draftValues || {}) }; delete relationDraftValues.childNodeIds; delete relationDraftValues.parentGroupId; return [ { ...source, draftValues: { ...relationDraftValues, elementType: categoryLabel, keyElement: true, keyElementType: categoryLabel, storyboardCategory: category, storyboardDerivedKeyElement: true, storyboardKeyElementSourceId: sourceRelationId, }, id: targetId, x: source.x + 48, y: source.y + 48, }, ...current, ]; }); setSelectedNodeIds([targetId]); setSelectedEdgeId(null); return targetId; }, [nodes, recordCanvasShortcutSnapshot], ); const duplicateStoryboardElement = useCallback( (nodeId: string): string | null => { const source = nodes.find((node) => node.id === nodeId); if (!source) return null; const duplicateId = makeId("copy"); const duplicateDraftValues = { ...(source.draftValues || {}) }; delete duplicateDraftValues.childNodeIds; delete duplicateDraftValues.parentGroupId; delete duplicateDraftValues.storyboardDerivedKeyElement; delete duplicateDraftValues.storyboardKeyElementSourceId; recordCanvasShortcutSnapshot(); setNodes((current) => [ { ...source, draftValues: { ...duplicateDraftValues, storyboardDuplicateOf: source.id, }, id: duplicateId, title: `${source.title} - 副本`, x: source.x + 48, y: source.y + 48, }, ...current, ]); setSelectedNodeIds([duplicateId]); setSelectedEdgeId(null); return duplicateId; }, [nodes, recordCanvasShortcutSnapshot], ); const deleteStoryboardElement = useCallback( (nodeId: string) => { deleteNode(nodeId, { silent: true }); }, [deleteNode], ); const generateStoryboardText = useCallback( async (nodeId: string, prompt: string, model: string) => { const node = nodes.find((item) => item.id === nodeId); const normalizedPrompt = prompt.trim(); if (!node || !normalizedPrompt) return; const definition = getConfiguredFunction( videoCanvasFunctionConfig, "story-script", ); const preferredModelRef = getFunctionModel( videoCanvasFunctionConfig, definition, model, ); const composedPrompt = composeFunctionPrompt( definition, normalizedPrompt, ); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generationStatus: "pending", params: { ...getNodeParams(item), model: preferredModelRef || "auto", prompt: normalizedPrompt, }, prompt: normalizedPrompt, }, } : item, ), ); try { const response = await workflowAnalysisService.analyze({ mode: "story-script", nodeTitle: node.title, preferredModelRef, prompt: composedPrompt, }); if (!response.success) { throw new Error(response.error || "文本生成失败"); } const content = formatStoryboardTextGenerationResult( response.data || {}, node.title, ); if (!content) throw new Error("文本生成未返回结果"); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), content: [content], generated: true, generationStatus: "complete", modelDisplayName: response.data?.model || getCanvasModelDisplayName( preferredModelRef, canvasModelOptions, ), params: { ...getNodeParams(item), model: preferredModelRef || "auto", prompt: normalizedPrompt, }, prompt: normalizedPrompt, rows: response.data?.analysisRows || item.draftValues?.rows, }, } : item, ), ); void mutateMembershipSummary(); toast.success(`${node.title} 已生成文本结果`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, draftValues: { ...(item.draftValues || {}), generationStatus: "failed", }, } : item, ), ); const message = error instanceof Error ? error.message : "文本生成失败"; toast.error(message); throw error; } }, [ canvasModelOptions, mutateMembershipSummary, nodes, videoCanvasFunctionConfig, ], ); const generateStoryboardImage = useCallback( async (nodeId: string, request: StoryboardImageGenerationRequest) => { const node = nodes.find((item) => item.id === nodeId); const normalizedPrompt = request.prompt.trim(); if (!node || !normalizedPrompt) return; const definition = getConfiguredFunction( videoCanvasFunctionConfig, request.presetKey || "storyboard-image", ); const preferredModelRef = getFunctionModel( videoCanvasFunctionConfig, definition, request.model, ); const promptWithStyle = request.styleLabel ? `${normalizedPrompt}\n\n视觉风格:${request.styleLabel}` : normalizedPrompt; const composedPrompt = composeFunctionPrompt(definition, promptWithStyle); const referenceImages = Array.from( new Set(request.referenceImages.map((url) => url.trim()).filter(Boolean)), ).slice(0, 14); recordCanvasShortcutSnapshot(); setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: "生成中", draftValues: { ...(item.draftValues || {}), generationProgress: 2, generationStatus: "pending", params: { ...getNodeParams(item), aspectRatio: request.aspectRatio, imageCount: request.imageCount, imageQuality: request.imageQuality, imageSize: request.imageSize, model: preferredModelRef || "auto", presetKey: request.presetKey || "", prompt: normalizedPrompt, referenceImages, styleLabel: request.styleLabel || "", }, prompt: normalizedPrompt, }, } : item, ), ); try { const response = await imageGenerationService.generateImage({ aspectRatio: request.aspectRatio, imageCount: request.imageCount, imageSize: request.imageSize, model: preferredModelRef, prompt: composedPrompt, promptExtend: true, providerImageParams: { quality: request.imageQuality === "低画质" ? "low" : request.imageQuality === "高画质" ? "high" : "medium", }, referenceImage: referenceImages[0], referenceImages: referenceImages.slice(1), }); const generatedUrls = response.success ? (response.data?.images || []) .map((image) => image.originalUrl || image.thumbnailUrl) .filter(Boolean) : []; if (!generatedUrls.length) { throw new Error(response.error || "图片生成未返回结果"); } setNodes((current) => { const source = current.find((item) => item.id === nodeId) || node; const buildDraftValues = (mediaUrl: string) => ({ ...(source.draftValues || {}), generated: true, generationProgress: 100, generationStatus: "complete", mediaType: "image", mediaUrl, modelDisplayName: response.data?.model || getCanvasModelDisplayName( preferredModelRef, canvasModelOptions, node.draftValues?.modelDisplayName as string | undefined, ), params: { ...getNodeParams(source), aspectRatio: request.aspectRatio, imageCount: request.imageCount, imageQuality: request.imageQuality, imageSize: request.imageSize, model: preferredModelRef || "auto", presetKey: request.presetKey || "", prompt: normalizedPrompt, referenceImages, styleLabel: request.styleLabel || "", }, prompt: normalizedPrompt, styleLabel: request.styleLabel || "", url: [mediaUrl], }); const updated = current.map((item) => item.id === nodeId ? { ...item, inputType: "image" as const, kind: "media" as const, subtitle: "生成结果", draftValues: buildDraftValues(generatedUrls[0]), } : item, ); const extraNodes = generatedUrls.slice(1).map((mediaUrl, index) => ({ ...source, draftValues: buildDraftValues(mediaUrl), id: makeId("image"), inputType: "image" as const, kind: "media" as const, subtitle: "生成结果", title: `${node.title} ${index + 2}`, x: source.x + (index + 1) * 48, y: source.y + (index + 1) * 48, })); return [...updated, ...extraNodes]; }); void mutateMembershipSummary(); toast.success(`${node.title} 已生成${generatedUrls.length}张图片`); } catch (error) { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, subtitle: node.subtitle, draftValues: { ...(item.draftValues || {}), generationStatus: "failed", }, } : item, ), ); const message = error instanceof Error ? error.message : "图片生成失败"; toast.error(message); throw error; } }, [ canvasModelOptions, mutateMembershipSummary, nodes, recordCanvasShortcutSnapshot, videoCanvasFunctionConfig, ], ); const referenceStoryboardItem = useCallback( (item: VideoCanvasStoryboardItem) => { const activation = item.keyElementCategory ? "preview" : "locate"; const textContent = item.textContent || item.prompt; if (!(item.mediaUrl || textContent || item.mediaType === "video")) { toast.error("当前素材还没有可引用的内容"); return; } if (activation === "preview" && !item.mediaUrl) { toast.error("当前关键元素还没有可预览的图片"); return; } const mediaUrl = item.mediaUrl ? new URL(item.mediaUrl, window.location.origin).toString() : ""; const previewUrl = item.previewUrl ? new URL(item.previewUrl, window.location.origin).toString() : ""; storyboardReferenceSequenceRef.current += 1; setStoryboardAgentOpen(true); setStoryboardComposerReference({ activation, contentType: item.mediaType === "text" ? "text/plain" : item.contentType, mediaType: item.mediaType, ...(mediaUrl ? { mediaUrl } : {}), nodeId: item.id, requestId: `${item.id}:${Date.now()}:${storyboardReferenceSequenceRef.current}`, ...(textContent ? { textContent } : {}), title: item.title, ...(previewUrl ? { previewUrl } : {}), }); }, [], ); const processStoryboardAudio = useCallback( (nodeId: string, request: AudioToolRequest) => { const node = nodes.find((item) => item.id === nodeId); if (!node) return Promise.reject(new Error("音频节点不存在")); return processAudioResourceNode(node, request); }, [nodes, processAudioResourceNode], ); const downloadStoryboardAudio = useCallback( (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (node) void downloadMediaNode(node); }, [downloadMediaNode, nodes], ); const downloadStoryboardVideo = useCallback( (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (node) requestVideoDownload(node); }, [nodes, requestVideoDownload], ); const previewStoryboardVideo = useCallback( (nodeId: string) => { const node = nodes.find((item) => item.id === nodeId); if (node?.inputType === "video" && getNodeMediaUrl(node)) { setVideoPreviewNode(node); } }, [nodes], ); const updateStoryboardVideoDraft = useCallback( ( nodeId: string, updates: { mode: VideoGenerationMode; motionMentions: VideoCanvasMotionMention[]; prompt: string; referenceImages: string[]; settings: ComposerSettings & { duration: string }; }, ) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const params = (node.draftValues?.params || {}) as Record< string, unknown >; return { ...node, draftValues: { ...(node.draftValues || {}), motionMentions: updates.motionMentions, prompt: updates.prompt, params: { ...params, duration: normalizeVideoDurationValue( updates.settings.duration, ), mode: updates.mode, model: updates.settings.model, motionMentions: updates.motionMentions, prompt: updates.prompt, ratio: normalizeVideoRatioValue(updates.settings.ratio), referenceImages: updates.referenceImages, resolution: normalizeVideoResolutionValue( updates.settings.resolution, ), settings: { ...updates.settings, mode: updates.mode, }, }, }, }; }), ); }, [], ); const openStoryboardVideoEffectLibrary = useCallback((nodeId: string) => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setRoleLibraryTargetNodeId(null); setEffectLibraryTargetNodeId(nodeId); setSelectedNodeId(nodeId); setSelectedEdgeId(null); setActivePanel("effectLibrary"); }, []); const openStoryboardVideoRoleLibrary = useCallback( (nodeId: string, options: { requiresAgreement: boolean }) => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(null); setRoleLibraryTargetNodeId(nodeId); setSelectedNodeId(nodeId); setSelectedEdgeId(null); setActivePanel("videoRoleLibrary"); setVideoRoleCompliantLibraryVisible(options.requiresAgreement); setVideoRoleAgreementOpen( options.requiresAgreement && !videoRoleAgreementAccepted, ); }, [videoRoleAgreementAccepted], ); 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 effectLibraryAttachedEffect = useMemo(() => { if (!effectLibraryTargetNodeId) return null; const targetNode = nodes.find((node) => node.id === effectLibraryTargetNodeId); const attachedEffect = getVideoCanvasAttachedEffect(targetNode?.draftValues); if (attachedEffect) return attachedEffect; const sourceId = edges.find( (edge) => edge.target === effectLibraryTargetNodeId && isVideoEffectMaterialNode(nodes.find((node) => node.id === edge.source)), )?.source; const sourceNode = nodes.find((node) => node.id === sourceId); if (!sourceNode) return null; return { author: getDraftString(sourceNode, "author"), commercial: sourceNode.draftValues?.commercial !== false, id: getDraftString(sourceNode, "effectId"), model: getDraftString(sourceNode, "model"), prompt: getDraftString(sourceNode, "effectPrompt"), thumbnailUrl: getDraftString(sourceNode, "thumbnailUrl") || getNodeMediaUrl(sourceNode), title: getDraftString(sourceNode, "effectTitle") || sourceNode.title.replace(/^素材-特效-/, ""), } satisfies VideoCanvasAttachedEffect; }, [edges, effectLibraryTargetNodeId, nodes]); const roleLibraryAttachedRole = useMemo(() => { if (!roleLibraryTargetNodeId) return null; const targetNode = nodes.find((node) => node.id === roleLibraryTargetNodeId); return getVideoCanvasAttachedRole(targetNode?.draftValues); }, [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 selectedVideoCropRect = normalizeVideoCropRect( selectedVideoMediaNode ? videoCropRects[selectedVideoMediaNode.id] || selectedVideoMediaNode.draftValues?.cropRect || DEFAULT_VIDEO_CROP_RECT : DEFAULT_VIDEO_CROP_RECT, ); const selectedVideoPixelDimensions = getVideoNodePixelDimensions(selectedVideoMediaNode); const selectedVideoCropOutputDimensions = getVideoCropOutputDimensions( selectedVideoCropRect, selectedVideoPixelDimensions.width, selectedVideoPixelDimensions.height, ); const selectedVideoCropLabel = formatVideoCropDimensions( selectedVideoCropOutputDimensions.width, selectedVideoCropOutputDimensions.height, "x", ); 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 (
{expandedVideoStoryNode ? ( setExpandedVideoStoryNodeId(null)} /> ) : null} {lineConnectState ? (
正在跟随
按 ESC 退出
) : null} {referenceSelectionTargetId ? ( setReferenceSelectionTargetId(null)} onReturn={() => locateCanvasNode(referenceSelectionTargetId)} /> ) : null} {markSelectionTargetId ? ( setMarkSelectionTargetId(null)} onReturn={() => locateCanvasNode(markSelectionTargetId)} /> ) : null} {cancelGenerationConfirmNodeId ? ( setCancelGenerationConfirmNodeId(null)} onConfirm={() => setCancelGenerationConfirmNodeId(null)} /> ) : null}
0 ? `transform ${viewportTransitionMs}ms ease` : undefined, width: CANVAS_EXTENT, }} > {edgesHidden ? 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, currentTimeSeconds) => { void extractVideoFrameNode(node.id, kind, currentTimeSeconds); }} onGenerateVideoHd={() => generateVideoHdUpscaleNode(node.id)} onUpdateVideoHd={(patch) => updateVideoHdUpscaleSettings(node.id, patch)} onProcessAudio={(request) => processAudioResourceNode(node, request)} onDownloadAudio={() => downloadMediaNode(node)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onOpenScriptWorkflow={() => openStoryScriptWorkflow(node.id)} onOpenVideoStory={() => setExpandedVideoStoryNodeId(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)} nodeCreationConfig={videoCanvasFunctionConfig.nodeCreation} videoHdConfig={videoCanvasFunctionConfig.videoHd} /> ))} {referenceSelectionTargetId ? edges .filter((edge) => edge.target === referenceSelectionTargetId) .map((edge) => nodes.find((node) => node.id === edge.source)) .filter((node): node is CloneNode => Boolean(node)) .filter(isVideoReferenceImageNode) .map((node) => (
{ event.preventDefault(); event.stopPropagation(); removeVideoReferenceFromTarget( node.id, referenceSelectionTargetId, ); }} 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) ) } onUpdate={(markId, selectedIndex) => updateVideoCanvasMagicMarks(markSelectionTargetId, (current) => current.map((mark) => mark.id === markId ? { ...mark, selectedIndex: Math.max( 0, Math.min(mark.labelOptions.length - 1, selectedIndex) ), } : mark ) ) } /> ); }) : null}
{nodes.length === 0 && !activePanel && canvasViewMode === "workflow" ? (
双击画布 自由生成节点
{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} uploadAccept={videoCanvasFunctionConfig.nodeCreation.upload.accept} /> ) : 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")} onDownload={() => requestVideoDownload(selectedVideoMediaNode)} onOpenHd={() => createVideoHdUpscaleFromVideo(selectedVideoMediaNode.id)} onPreview={() => setVideoPreviewNode(selectedVideoMediaNode)} 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" ? ( <> updateVideoCropRect(selectedVideoMediaNode.id, rect)} /> 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]) } onChangeRegion={(nextRegion) => commitSubtitleEraseRegions( selectedVideoMediaNode.id, selectedSubtitleEraseRegions.map((region) => region.id === nextRegion.id ? nextRegion : 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" ? ( setActiveVideoToolbarMode(null)} previewUrl={getVideoNodePreviewUrl(selectedVideoMediaNode) || getNodeMediaUrl(selectedVideoMediaNode)} title={selectedVideoMediaNode.title} onApply={() => applyVideoClipSelection(selectedVideoMediaNode.id)} onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)} /> ) : 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) ) } onClearMagicMarks={() => updateVideoCanvasMagicMarks(selectedNode.id, () => []) } onOpenEffectLibrary={() => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(selectedNode.id); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel("effectLibrary"); setLineConnectState(null); }} onOpenRoleLibrary={({ requiresAgreement }) => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(null); setRoleLibraryTargetNodeId(selectedNode.id); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel("videoRoleLibrary"); setVideoRoleCompliantLibraryVisible(requiresAgreement); setVideoRoleAgreementOpen( requiresAgreement && !videoRoleAgreementAccepted, ); setLineConnectState(null); }} onOpenSubjectLibrary={() => { setReferenceSelectionTargetId(null); setMarkSelectionTargetId(null); setEffectLibraryTargetNodeId(null); setRoleLibraryTargetNodeId(null); setSubjectLibraryTargetNodeId(selectedNode.id); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); setCanvasCommand(null); setNodeContextMenu(null); setConnectionMenu(null); setActivePanel("videoSubjectLibrary"); setLineConnectState(null); }} onModeChange={(mode) => { setNodes((current) => current.map((node) => { if (node.id !== selectedNode.id) return node; const params = (node.draftValues?.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; return { ...node, draftValues: { ...(node.draftValues || {}), params: { ...params, mode, settings: { ...settings, mode }, }, }, }; }), ); }} onModelChange={({ model, modelLabel }) => { const rule = resolveVideoReferenceSelectionRule( videoCanvasFunctionConfig.videoReferenceSelection, `${modelLabel} ${model}`, ); const referenceEdges = edges.filter((edge) => { if (edge.target !== selectedNode.id) return false; return isVideoReferenceImageNode( nodes.find((node) => node.id === edge.source), ); }); const retainedReferenceIds = new Set( referenceEdges.slice(0, rule.maxImages).map((edge) => edge.id), ); const hasRoleReference = Boolean( getVideoCanvasAttachedRole(selectedNode.draftValues), ); if (referenceEdges.length > rule.maxImages) { setEdges((current) => current.filter( (edge) => edge.target !== selectedNode.id || !isVideoReferenceImageNode( nodes.find((node) => node.id === edge.source), ) || retainedReferenceIds.has(edge.id), ), ); } setReferenceSelectionTargetId(null); setNodes((current) => current.map((node) => { if (node.id !== selectedNode.id) return node; const params = (node.draftValues?.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const nextNode = { ...node, draftValues: { ...(node.draftValues || {}), params: { ...params, model, settings: { ...settings, model, modelLabel }, }, }, }; return withVideoGenerationMode( nextNode, retainedReferenceIds.size > 0 ? rule.mode : hasRoleReference ? "first-frame" : "text-to-video", ); }), ); }} onRemoveEffect={() => { const legacyEffectIds = new Set( selectedTargetSourceNodes .filter(isVideoEffectMaterialNode) .map((node) => node.id), ); setNodes((current) => current .filter((node) => !legacyEffectIds.has(node.id)) .map((node) => node.id === selectedNode.id ? { ...node, draftValues: setVideoCanvasAttachedEffect( node.draftValues, null, ), } : node, ), ); setEdges((current) => current.filter( (edge) => !( legacyEffectIds.has(edge.source) && edge.target === selectedNode.id ), ), ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onRemoveReference={(sourceId) => removeVideoReferenceFromTarget(sourceId, selectedNode.id) } onRemoveRole={() => { const legacyRoleIds = new Set( selectedTargetSourceNodes .filter(isVideoRoleMaterialNode) .map((node) => node.id), ); const hasImageReference = selectedTargetSourceNodes.some( (node) => node.inputType === "image", ); setNodes((current) => current .filter((node) => !legacyRoleIds.has(node.id)) .map((node) => { if (node.id !== selectedNode.id) return node; const nextDraftValues = setVideoCanvasAttachedRole( node.draftValues, null, ); const params = (nextDraftValues.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const mode = hasImageReference ? "first-frame" : "text-to-video"; return { ...node, draftValues: { ...nextDraftValues, params: { ...params, mode, settings: { ...settings, mode }, }, }, }; }), ); setEdges((current) => current.filter( (edge) => !(legacyRoleIds.has(edge.source) && edge.target === selectedNode.id), ), ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onRemoveSubject={(subjectId) => { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: removeVideoCanvasAttachedSubject( node.draftValues, subjectId, ), } : node, ), ); setSelectedNodeId(selectedNode.id); setSelectedEdgeId(null); }} onAdvancedSettingsChange={(updates) => { setNodes((current) => current.map((node) => { if (node.id !== selectedNode.id) return node; const params = (node.draftValues?.params || {}) as Record; return { ...node, draftValues: { ...(node.draftValues || {}), params: mergeVideoCanvasAdvancedSettings(params, updates), }, }; }), ); }} onParameterSettingsChange={(updates) => { setNodes((current) => current.map((node) => { if (node.id !== selectedNode.id) return node; const params = (node.draftValues?.params || {}) as Record; return { ...node, draftValues: { ...(node.draftValues || {}), params: mergeVideoCanvasGenerationParameterSettings( params, updates, videoCanvasFunctionConfig.videoGenerationParameters, ), }, }; }), ); }} 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, ), ); }} onSmartStoryboardDurationChange={(durationSeconds) => { setNodes((current) => current.map((node) => { if (node.id !== selectedNode.id) return node; const params = (node.draftValues?.params || {}) as Record; const settings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; return { ...node, draftValues: { ...(node.draftValues || {}), params: { ...params, duration: durationSeconds, settings: { ...settings, duration: `${durationSeconds}s`, }, }, }, }; }), ); }} onSmartStoryboardSave={(smartStoryboard) => { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: setVideoCanvasSmartStoryboard( node.draftValues, smartStoryboard, ), } : node, ), ); }} onMentionsChange={(promptMentions) => { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: { ...(node.draftValues || {}), promptMentions, params: { ...((node.draftValues?.params as | Record | undefined) || {}), promptMentions, }, }, } : node, ), ); }} onMotionMentionsChange={(motionMentions) => { setNodes((current) => current.map((node) => node.id === selectedNode.id ? { ...node, draftValues: { ...(node.draftValues || {}), motionMentions, params: { ...((node.draftValues?.params as | Record | undefined) || {}), motionMentions, }, }, } : 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")} onBeforeCanvasChange={flushCanvasBeforeWorkspaceChange} onCommitTitle={commitTitle} onDraftTitleChange={setDraftTitle} onViewModeChange={(mode) => { void handleCanvasViewModeChange(mode); }} routerBack={() => router.push("/")} siteLogo={siteBrandSettings?.siteLogo} siteLogoDark={siteBrandSettings?.siteLogoDark} siteName={resolvedSiteName} viewModeSavePending={viewModeSavePending} /> {canvasViewMode === "workflow" ? ( <> { 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={toggleCanvasGridSnap} onSetZoom={setZoomAroundViewportCenter} onViewportChange={setViewport} onZoomIn={() => zoomBy("in")} onZoomOut={() => zoomBy("out")} /> ) : null} {canvasViewMode === "storyboard" ? ( ) : null} {canvasViewMode === "storyboard" && !storyboardAgentOpen ? ( ) : null} {storyboardAgentMounted ? ( ) : null} {arrangePreview ? ( ) : null} {activePanel === "add" ? ( setActivePanel(null)} onGallerySelect={() => setActivePanel("assets")} onHistorySelect={() => setActivePanel("historyPicker")} onUploadFiles={addUploadedFiles} uploadAccept={videoCanvasFunctionConfig.nodeCreation.upload.accept} /> ) : null} {activePanel === "workflow" && videoCanvasFunctionConfig.toolbox.enabled ? ( 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} selectedEffectId={effectLibraryAttachedEffect?.id || ""} /> ) : null} {activePanel === "history" ? ( setActivePanel(null)} onOpenWatermarkRules={openHistoryWatermarkRules} onUseNode={reuseHistoryAssetNode} /> ) : null} {activePanel === "historyPicker" ? ( setActivePanel(null)} onConfirm={addHistoryPickerNodes} /> ) : null} {historyWatermarkRulesOpen ? ( ) : null} {videoPreviewNode ? ( setVideoPreviewNode(null)} title={videoPreviewNode.title || "视频"} /> ) : 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)} onOpenCustomerService={() => setCustomerServiceOpen(true)} /> ) : null} {customerServiceOpen ? ( setCustomerServiceOpen(false)} siteLogo={siteBrandSettings?.siteLogoDark || siteBrandSettings?.siteLogo} siteName={resolvedSiteName} /> ) : null} {activePanel === "roleLibrary" ? ( setActivePanel(null)} roles={videoCanvasFunctionConfig.rolePresets} /> ) : null} {activePanel === "videoRoleLibrary" ? ( <> { setActivePanel(null); setRoleLibraryTargetNodeId(null); setVideoRoleAgreementOpen(false); setVideoRoleCompliantLibraryVisible(false); }} presets={videoCanvasFunctionConfig.rolePresets} selectedRole={roleLibraryAttachedRole} showCompliantTab={videoRoleCompliantLibraryVisible} /> {videoRoleAgreementOpen ? ( { setVideoRoleAgreementAccepted(true); setVideoRoleAgreementOpen(false); try { window.localStorage.setItem( VIDEO_ROLE_AGREEMENT_STORAGE_KEY, "1", ); } catch {} }} onClose={() => { setVideoRoleAgreementOpen(false); setActivePanel(null); setRoleLibraryTargetNodeId(null); setVideoRoleCompliantLibraryVisible(false); }} /> ) : null} ) : null} {activePanel === "videoSubjectLibrary" ? ( { setActivePanel(null); setSubjectLibraryTargetNodeId(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 ? ( persistDirectorCapture(directorConsoleNode, capture) } onCaptureDeleted={(capture) => scheduleDirectorCaptureDeletion(directorConsoleNode, capture) } onAiImportGenerate={generateDirectorAiImport} onAiImportSourceUpload={(payload) => uploadDirectorAiImportSource(directorConsoleNode, payload) } onClose={() => setDirectorConsoleNodeId(null)} onOpenHistoryWatermarkRules={openHistoryWatermarkRules} onPanoramaAiGenerate={(payload) => generateDirectorPanorama(directorConsoleNode, payload) } onPanoramaHistoryNodeUse={(historyNode) => { const mediaType = getDraftString(historyNode, "mediaType") || historyNode.inputType; if ( mediaType !== "image" || !getNodeReferenceMediaUrl(historyNode) ) { toast.error("全景图仅支持图片素材"); return Promise.resolve({ error: "全景图仅支持图片素材", success: false, }); } return useDirectorPanoramaHistoryItem( directorConsoleNode, historyNode.id, ); }} onPanoramaHistoryUse={({ sourceNodeId }) => useDirectorPanoramaHistoryItem(directorConsoleNode, sourceNodeId) } onPanoramaRemoved={({ edgeId, sourceNodeId }) => { setEdges((current) => current.filter( (edge) => !( edge.id === edgeId && edge.source === sourceNodeId && edge.target === directorConsoleNode.id ), ), ); }} onPanoramaUpload={(payload) => uploadDirectorPanorama(directorConsoleNode, payload) } onProjectChange={(project) => updateDirectorProject(directorConsoleNode.id, project) } onSendCaptures={(captures) => { void sendDirectorSnapshotsToCanvas(directorConsoleNode, captures); }} panorama={directorPanoramaConnection} panoramaConfig={videoCanvasFunctionConfig.directorDesk.panorama} panoramaHistory={directorPanoramaHistory} posePresets={videoCanvasFunctionConfig.directorDesk.posePresets} project={directorProject} showBatchCaptureControls={ videoCanvasFunctionConfig.directorDesk.showBatchCaptureControls } src={videoCanvasFunctionConfig.directorDesk.embedUrl} /> ) : 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({ canvasId, canvasViewMode, draftTitle, onAccount, onBeforeCanvasChange, onCommitTitle, onDraftTitleChange, onViewModeChange, routerBack, siteLogo, siteLogoDark, siteName, viewModeSavePending, }: { canvasId: string; canvasViewMode: VideoCanvasViewMode; draftTitle: string; onAccount: () => void; onBeforeCanvasChange: () => Promise; onCommitTitle: () => void; onDraftTitleChange: (value: string) => void; onViewModeChange: (mode: VideoCanvasViewMode) => void; routerBack: () => void; siteLogo?: string | null; siteLogoDark?: string | null; siteName?: string | null; viewModeSavePending: boolean; }) { const { data: membershipSummary } = useSWR("/api/user/membership-summary", fetcher); const credits = Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0)); return (
onDraftTitleChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }} value={draftTitle} />
); } function StoryboardEmptyState({ label }: { label: string }) { return (
{label}
); } function StoryboardTextItem({ item, onOpen, selected = false, }: { item: VideoCanvasStoryboardItem; onOpen: () => void; selected?: boolean; }) { return ( ); } function StoryboardMediaItem({ item, onOpen, onReference, selected = false, }: { item: VideoCanvasStoryboardItem; onOpen: () => void; onReference?: () => void; selected?: boolean; }) { return (
{onReference ? ( ) : null}
); } function VideoCanvasStoryboardDetailMoreMenu({ itemId, menuLabel, onDelete, onDuplicate, onSetCategory, }: { itemId: string; menuLabel: string; onDelete: () => void; onDuplicate: () => void; onSetCategory: (category: VideoCanvasKeyElementCategory) => void; }) { const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [categoryMenuOpen, setCategoryMenuOpen] = useState(false); const moreMenuRef = useRef(null); const closeMoreMenu = useCallback(() => { setMoreMenuOpen(false); setCategoryMenuOpen(false); }, []); useEffect(() => { closeMoreMenu(); }, [closeMoreMenu, itemId]); useEffect(() => { if (!moreMenuOpen) return; const closeOnOutsidePointer = (event: PointerEvent) => { if ( event.target instanceof Node && !moreMenuRef.current?.contains(event.target) ) { closeMoreMenu(); } }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); closeMoreMenu(); }; document.addEventListener("pointerdown", closeOnOutsidePointer, true); window.addEventListener("keydown", closeOnEscape, true); return () => { document.removeEventListener("pointerdown", closeOnOutsidePointer, true); window.removeEventListener("keydown", closeOnEscape, true); }; }, [closeMoreMenu, moreMenuOpen]); return (
{moreMenuOpen ? (
{categoryMenuOpen ? (
{VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS.map((option) => ( ))}
) : null}
) : null}
); } const STORYBOARD_DETAIL_MODULE_OPTIONS = [ { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, ] as const; function StoryboardTextGenerator({ billingDefinition, defaultModel, item, membershipSummary, modelOptions, onGenerate, }: { billingDefinition?: VideoCanvasFunctionDefinition; defaultModel: string; item: VideoCanvasStoryboardItem; membershipSummary?: MembershipSummaryResponse; modelOptions: CanvasModelOption[]; onGenerate: (prompt: string, model: string) => Promise; }) { const initialModel = normalizeModelRef(item.modelRef || defaultModel) || "auto"; const [expanded, setExpanded] = useState(false); const [generating, setGenerating] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [prompt, setPrompt] = useState(item.prompt); const [selectedModel, setSelectedModel] = useState(initialModel); const [translating, setTranslating] = useState(false); const textModelOptions = useMemo( () => filterCanvasModelsByKind(modelOptions, "multimodal"), [modelOptions], ); const billingDisplay = useMemo( () => resolveVideoCanvasBillingDisplay({ functionDefinition: billingDefinition, membershipSummary, modelKind: "multimodal", modelOptions, modelRef: selectedModel, }), [billingDefinition, membershipSummary, modelOptions, selectedModel], ); useEffect(() => { setPrompt(item.prompt); setSelectedModel(initialModel); setExpanded(false); setModelMenuOpen(false); }, [initialModel, item.id, item.prompt]); const translatePrompt = useCallback(async () => { const sourcePrompt = prompt.trim(); if (!sourcePrompt || translating) return; setTranslating(true); try { const response = await fetch("/api/video-canvas/prompt-tools", { body: JSON.stringify({ marks: [], mode: "translate", prompt: sourcePrompt, references: [], }), headers: { "Content-Type": "application/json" }, method: "POST", }); const payload = (await response.json().catch(() => null)) as { data?: { text?: string }; error?: string; success?: boolean; } | null; const translated = String(payload?.data?.text || "").trim(); if (!response.ok || !payload?.success || !translated) { throw new Error(payload?.error || "翻译失败"); } setPrompt(translated); } catch (error) { toast.error(error instanceof Error ? error.message : "翻译失败"); } finally { setTranslating(false); } }, [prompt, translating]); const submit = useCallback(async () => { const normalizedPrompt = prompt.trim(); if (!normalizedPrompt || generating) return; setGenerating(true); try { await onGenerate(normalizedPrompt, selectedModel); } catch { // The parent reports the backend error and restores the persisted state. } finally { setGenerating(false); } }, [generating, onGenerate, prompt, selectedModel]); const editor = (