"use client"; import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { GroupStoryboardPreview } from "./GroupStoryboardPreview"; import { CanvasGroupToolbar } from "./CanvasGroupToolbar"; import { isCanvasGroup, groupMembers, makeCanvasGroup, ungroupCanvasNodes, moveCanvasSelection, layoutCanvasGroup, migrateLegacyGroups, exportCanvasGroup, instantiateCanvasGroup, groupBounds, assignGroupMembers } from "@/lib/video-canvas/groups"; import { compileGroupRun } from "@/lib/video-canvas/group-execution"; import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedText, connectedInputNodes, assertConnectedInputsReady, withConnectedText, connectedImageUrls } from "@/lib/video-canvas/connections"; import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent"; import { SmartEditDialog } from "./smart-edit/SmartEditDialog"; import { SmartEditNodeCard, SmartEditNodeComposer, type SmartEditMode } from "./smart-edit/SmartEditNode"; import { smartEditRequestSchema, type EditPlan } from "@/lib/video-canvas/smart-edit/contract"; import { AudioGenerationComposer } from "./AudioGenerationComposer"; import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel"; import { resolveTVstarGenerationReferences } from "./tvstar/generation-references"; import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing"; import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output"; import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel"; import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import dynamic from "next/dynamic"; import { signOut } from "next-auth/react"; import { PromptTranslateButton } from "@/components/canvas/PromptTranslateButton"; import { useVideoCanvasTasks } from "@/hooks/use-video-canvas-tasks"; import { requireAnalysisResult } from "@/lib/video-canvas/task-contract"; const DirectorStudioDialog = dynamic( () => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog), { ssr: false }, ); import { directorMotionPrompt, resizeDirectorMotion } from "@/lib/video-canvas/director-motion"; import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene"; import { chooseStoryVideoSettings, filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, isCanvasLanguageModel, isCanvasVisionModel, type CanvasModelKind } from "@/lib/video-canvas/model-matching"; import { ScanText, AudioLines, ArrowUp, Bell, BookOpen, BoxSelect, Camera, Clapperboard, Check, ChevronDown, ChevronRight, Clock3, Copy, Download, Ellipsis, FileText, FolderOpen, GitBranch, Headphones, Mic, HelpCircle, Image as ImageIcon, Keyboard, Languages, Layers, Loader2, Maximize2, Minimize2, Music2, Move, Pause, Plus, Play, Redo2, RotateCcw, Search, Scissors, Settings2, SlidersHorizontal, Smile, Sparkles, Star, Trash2, Upload, UserRound, Video, Wand2, Undo2, Zap, X, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { createPortal } from "react-dom"; import { type ChangeEvent as ReactChangeEvent, type DragEvent as ReactDragEvent, Fragment, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type CSSProperties, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import { ZCODE_VIDEO_CANVAS_AUTHORIZED } from "@/lib/ai/canvas-agent-runtime/authorization"; import { CanvasAgentUiListener } from "./chat-panel/canvas-agent-ui-listener"; import type { CanvasAgentUiCommand } from "@/lib/ai/canvas-agent-runtime/ui-command"; import { VideoCanvasAgentPanel } from "./chat-panel/video-canvas-agent-panel"; import { mergeVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-protocol"; import useSWR from "swr"; import { getGenerationCreditCostFromImageGenConfig, getImageCreditCostFromConfig, } from "@/lib/ai/image-model-pricing"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { defaultPointsSystemConfig, getImageToolCreditCost, normalizePointsSystemConfig, type ImageToolCreditFeature, type PointsSystemConfig, } from "@/lib/points/config"; import { cn, fetcher } from "@/lib/utils"; import { canvasService, imageGenerationService, videoGenerationService, } from "@/services/canvas"; import { canvasGenerationCount, canvasImageRequest, canvasImageResultUrls } from "@/lib/video-canvas/generation-contract"; import { anglePresets, angleRequestParameters } from "@/lib/video-canvas/angle-settings"; import { downloadCanvasMedia } from "@/lib/video-canvas/download"; import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result"; import { VideoArchiveButton } from "@/components/canvas/VideoArchiveButton"; import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog"; import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools"; import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import"; import { getUploadedMediaNodeSize } from "@/lib/video-canvas/media-node-size"; import type { CanvasInitialProject } from "@/types/canvas"; import { getVideoCanvasFunctionBillingCopy, type VideoCanvasFunctionConfig, type VideoCanvasFunctionDefinition, type VideoCanvasEffectPreset, type VideoCanvasMotionPreset, type VideoCanvasRolePreset, defaultVideoCanvasFunctionConfig, defaultVideoCanvasMotionPresets, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; type ActivePanel = | "add" | "workflow" | "assets" | "assetManager" | "effectLibrary" | "history" | "historyPicker" | "help" | "roleLibrary" | "characterStudio" | "tvstar" | "videoRoleLibrary" | "shortcuts" | "styleLibrary" | "support" | "tutorial" | "skills" | "share" | "publish" | "member" | "notifications" | "account" | null; type ActiveStarterScene = "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | null; type ActivePresetControlMode = | "character-face-three-view" | "character-design-sheet" | "scene-design-sheet" | "product-design-sheet" | "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | "story-beat-grid-4" | "multi-view-nine-grid" | "continuous-storyboard-grid-25" | "cinematic-lighting-correction" | "frame-prediction-plus-3s" | "frame-prediction-minus-5s" | null; type SceneToolbarMenu = "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | null; type ComposerMenu = | "style" | "mark" | "focus" | "model" | "mode" | "ratio" | "resolution" | "duration" | "camera" | "panorama" | "translate" | "settings" | "count" | "reference" | "slash" | null; type MemberStoreTab = "vip" | "history" | "credits"; type VideoToolbarMode = "clip" | "crop" | "subtitleErase" | "analysis" | null; type VideoToolbarMenu = "hd" | "subtitle" | "audio" | null; type VideoFrameExtractionKind = "first" | "last" | "current"; type SubtitleEraseMode = "smart" | "region"; type VideoAudioSplitMode = "voice" | "background" | "audio-video"; type SubtitleEraseRegion = { height: number; id: string; width: number; x: number; y: number; }; type SubtitleEraseRegionHistory = { future: SubtitleEraseRegion[][]; past: SubtitleEraseRegion[][]; }; type CapturedTool = { type?: string; toolKey?: string; modelKey?: string; modelName?: string; vendor?: string; scene?: string; modes?: string[]; settings?: string[]; advancedSettings?: string[]; propertyKeys?: string[]; generateTypes?: unknown; rules?: unknown; metadata?: { properties?: Record; config?: Record; generateTypes?: unknown; rules?: unknown; [key: string]: unknown; }; }; type CanvasLibraryAsset = { label: string; mediaType: NonNullable; type: string; url: string; }; type WorkflowPreset = { id?: string | number; uuid?: string; name?: string; title?: string; description?: string; cover?: string; }; type ToolboxTemplatePreset = WorkflowPreset & { accent: string; author: string; badge?: string; category: string; duration: string; steps: string[]; templateKey: string; usageCount: string; }; type RoleLibraryItem = VideoCanvasRolePreset; type RoleLibraryFilterKey = keyof RoleLibraryItem["filters"]; type CloneNodeKind = | "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "material-effect" | "material-role" | "material-style" | "script" | "video-story"; type CloneNode = { id: string; kind: CloneNodeKind; title: string; subtitle?: string; x: number; y: number; width: number; height: number; accent: string; tool?: CapturedTool; workflow?: WorkflowPreset; inputType?: "image" | "video" | "audio" | "text"; draftValues?: Record; }; type CloneEdge = { id: string; source: string; target: string; }; type VideoCanvasMagicMark = { clickX: number; clickY: number; id: string; imageUrl: string; labelOptions: string[]; selectedIndex: number; sourceNodeId: string; }; type VideoCanvasMarkAnalysisState = { clickX: number; clickY: number; sourceNodeId: string; targetNodeId: string; }; type PickObjectApiResponse = { error?: string; object?: { confidence?: number; labels?: string[]; name?: string; }; success?: boolean; }; type AssetMarketCard = readonly [title: string, author: string, count: number, model?: string]; type LineConnectState = { cursorCanvasX: number | null; cursorCanvasY: number | null; hoverNodeId?: string | null; targetId?: string | null; sourceId: string | null; }; type EndpointConnectionDragState = { anchorId: string; hasDragged: boolean; pointerId: number | null; side: ConnectionSide; startClientX: number; startClientY: number; }; type DragState = | { kind: "node"; id: string; startX: number; startY: number; nodeX: number; nodeY: number; ids?: string[]; } | { kind: "canvas"; startX: number; startY: number; viewportX: number; viewportY: number; }; type CanvasCommandState = { canvasX: number; canvasY: number; screenX: number; screenY: number; } | null; type StoryWorkflowStep = "shots" | "assets" | "prompts"; type NodeContextState = { nodeId: string; screenX: number; screenY: number; } | null; type ConnectionSide = "source" | "target"; type ConnectionMenuState = { nodeId: string; screenX: number; screenY: number; side: ConnectionSide; } | null; type PresetControlAnchor = { bottomY: number; centerX: number; topY: number; zoom: number; }; type ComposerSettings = { autoValidateMaterial?: boolean; audio?: string; camera: string; count: string; duration?: string; model: string; modelLabel?: string; panorama: string; ratio: string; resolution: string; searchEnabled?: boolean; }; type VideoComposerTabKey = | "text-to-video" | "reference-images" | "first-frame" | "first-last-frame" | "image-reference"; type VideoTargetComposerSubmitPayload = { magicMarks: VideoCanvasMagicMark[]; mode: VideoGenerationMode; prompt: string; settings: ComposerSettings & { duration: string; }; tab: VideoComposerTabKey; }; type CanvasModelOption = { id: string; modelId?: string; displayName: string; providerName?: string; providerType?: string; supportsVision?: boolean; supportsImageGen?: boolean; isMultimodal?: boolean; isDefault?: boolean; isSubModel?: boolean; capabilities?: unknown; imageGenConfig?: { creditCostPerImage?: number | null; creditDiscountRate?: number | null; creditCostPerVideo?: number | null; videoDurationOptions?: number[] | null; creditCostPerVideoByDuration?: Record | null; creditCostPerVideoByResolution?: Record | null; } | null; }; type AddModuleKind = "smart-edit" | "text" | "image" | "video" | "frame-analysis" | "video-clip" | "director" | "audio" | "script" | "legacy-script"; type CanvasCreateKind = AddModuleKind; type CanvasViewMode = "workflow" | "storyboard"; type TargetModuleActionLabel = | "自己编写内容" | "文生视频" | "图片反推提示词" | "文字生音乐" | "图生图" | "图片高清" | "参考" | "标记" | "风格" | "音频生视频" | "首尾帧生成视频" | "首帧生成视频" | "自己编写分镜脚本" | "生成高清视频" | "打开智能剪辑" | "讲解视频" | "批量广告" | "口播视频" | "素材混剪" | "打开导演台" | "剧本生成分镜脚本" | "视频参考生成分镜脚本" | "角色生成分镜脚本" | "上传视频拉片" | "从画布选择视频拉片" | "拉片分镜" | "拉片动态" | "拉片音乐"; type ImageToolbarAction = | "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | "localEdit" | "annotate" | "rotate" | "download" | "preview"; type StoryToolbarAction = "regenerate" | "create-storyboard" | "batch-video"; type ComposerSubmitPayload = { prompt: string; references: string[]; settings: ComposerSettings; slashScene: string | null; }; type LiblibVideoCanvasReplicaProps = { canvasId: string; initialProject?: CanvasInitialProject | null; isLoading?: boolean; }; type SiteBrandSettings = { siteName?: string | null; siteLogo?: string | null; siteLogoDark?: string | null; }; const DEFAULT_SITE_NAME = "AI Chatbot"; function resolveSiteName(siteName?: string | null) { return String(siteName || DEFAULT_SITE_NAME).trim() || DEFAULT_SITE_NAME; } function getSiteShowcaseName(siteName?: string | null) { return `${resolveSiteName(siteName)} 创作广场`; } type MembershipSummaryResponse = { isHighVip?: boolean; currentPlanId?: string; currentPlan?: { id?: string; name?: string; monthlyPrice?: number; annualPrice?: number; monthlyCredits?: number; dailyFreeCredits?: number; features?: string[]; badge?: string; highlighted?: boolean; }; summary?: { currentCredits?: number | null; dailyFreeCredits?: number | null; usedThisMonth?: number | null; monthlyLimit?: number | null; remainingThisMonth?: number | null; totalImages?: number | null; }; pointsSystemConfig?: PointsSystemConfig | null; }; type MembershipPlan = { id: string; name: string; monthlyPrice: number; annualPrice: number; monthlyCredits: number; dailyFreeCredits: number; features: string[]; badge?: string; highlighted?: boolean; }; type PointsPackage = { id: string; name: string; points: number; price: number; currency: string; isEnabled: boolean; }; type AccountCenterResponse = { profile?: { name?: string; email?: string; currentPlanName?: string; currentPlanId?: string; availablePoints?: number; monthlyCredits?: number; dailyFreeCredits?: number; }; usageRecords?: Array<{ id: string; detail: string; status: "consumed" | "earned"; amount: number; createdAt: string; }>; billingRecords?: Array<{ id: string; date: string; category: string; amount: number; status: "completed" | "pending" | "failed"; invoice: string; }>; }; type VideoCanvasBillingSettings = { membershipConfig?: { enabled?: boolean; currency?: string; annualDiscountLabel?: string; plans?: MembershipPlan[]; }; pointsPackages?: PointsPackage[]; dulupayConfig?: { enabled?: boolean; defaultType?: string; }; }; type GridCellVisual = { fit?: "cover" | "contain"; objectPosition?: string; scale?: number; translateX?: number; translateY?: number; }; type GridCropRect = { height: number; width: number; x: number; y: number; }; type LocalEditRegion = { color: string; createdAt: number; id: string; index: number; prompt: string; rect: GridCropRect; }; type GridSplitSpec = { columns: number; detected: boolean; rects: GridCropRect[]; rows: number; }; type GridSplitState = { columns: number; detectedDivider: boolean; rects: GridCropRect[]; rows: number; selectedIndices: number[]; sourceMode: string; }; type AngleEditorSettings = { preset?: string; horizontalAngle: number; promptEnabled: boolean; verticalAngle: number; zoom: number; }; type VideoClipRange = { start: number; end: number; }; type VideoAnalysisRow = { id: string; shot: string; startTime: string; endTime: string; duration: string; sceneDescription: string; narrative: string; shotSize: string; cameraAngle: string; cameraMovement: string; focusDepth: string; lighting: string; backgroundMusic: string; voiceAndSfx: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; type StoryScriptRow = { id: string; shot: string; duration: string; scene: string; sceneDescription: string; shotSize: string; lighting: string; dialogue: string; backgroundMusic: string; voiceAndSfx: string; cameraMovement: string; imagePrompt: string; motionPrompt: string; keyframeUrl: string; }; type VideoAnalysisState = { status: "idle" | "loading" | "ready" | "error"; summary: string; rows: VideoAnalysisRow[]; error?: string; }; type VideoCropGenerateState = { status: "loading"; progress: number; }; type VideoAudioSplitState = { status: "loading"; mode: VideoAudioSplitMode; }; const capturedTools: CapturedTool[] = []; const capturedCounts: { byType?: Record } = {}; const capturedWorkflows: WorkflowPreset[] = []; const TOOL_TYPE_META: Record< string, { label: string; icon: LucideIcon; accent: string; inputType?: CloneNode["inputType"] } > = { image: { label: "图片", icon: ImageIcon, accent: "#8b96ff", inputType: "image" }, video: { label: "视频", icon: Video, accent: "#8b96ff", inputType: "video" }, text: { label: "文本", icon: FileText, accent: "#f0d06b", inputType: "text" }, audio: { label: "音频", icon: Music2, accent: "#9ee6bb", inputType: "audio" }, compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" }, }; const ADD_NODE_MODULES: Array<{ badge?: string; description: string; icon: LucideIcon; kind: AddModuleKind; label: string; }> = [ { kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText }, { kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon }, { kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video }, { kind: "smart-edit", label: "智能剪辑", description: "自动选片、卡点、字幕与成片", icon: Scissors, badge: "Beta" }, { kind: "director", label: "导演台", description: "3D空间多视角截图", icon: Settings2, badge: "NEW" }, { kind: "frame-analysis", label: "逐帧拉片", description: "上传视频,拆解分镜、动态与音乐", icon: ScanText, badge: "视频解析" }, { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, { kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "NEW" }, { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, ]; 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: "smart-edit", label: "智能剪辑", icon: Scissors }, { kind: "video-clip", label: "视频合成", icon: Scissors }, { kind: "script", label: "脚本生成", icon: Layers }, ]; const TARGET_MODULE_CENTER_OFFSETS: Record = { "smart-edit": { x: 728, y: 670 }, 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 }, "frame-analysis": { x: 420, 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 EMPTY_CANVAS_ACTIONS: Array<{ label: string; kind: AddModuleKind; icon: LucideIcon }> = [ { label: "图片生成", kind: "image", icon: ImageIcon }, { label: "视频生成", kind: "video", icon: Video }, { label: "音频生成", kind: "audio", icon: Music2 }, { label: "剧本生成", kind: "script", icon: FileText }, { label: "智能剪辑", kind: "smart-edit", icon: Scissors }, ]; const TOOLBOX_TEMPLATE_CATEGORIES = ["周星驰经典名场面", "电商展示", "镜头运动", "人物动画", "全部"] as const; const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [ { accent: "linear-gradient(135deg,#f5d789,#f08b62)", author: "LibTV 官方", badge: "预设", category: "周星驰经典名场面", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。", duration: "5s", id: "left-arc-slide", name: "【预设】左弧滑行", steps: ["参考图", "镜头轨迹", "Seedance 2.0", "视频"], templateKey: "left-arc-slide", usageCount: "4.2k", }, { accent: "linear-gradient(135deg,#98d5ff,#6c84ff)", author: "电商模板库", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。", duration: "5s", id: "phone-pop-product", name: "电商手机弹出效果", steps: ["产品图", "弹出动画", "高光", "视频"], templateKey: "phone-pop-product", usageCount: "3.6k", }, { accent: "linear-gradient(135deg,#dfc192,#8e623c)", author: "商品展示", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。", duration: "5s", id: "coffee-cup-entry", name: "咖啡杯出场", steps: ["商品图", "场景光", "蒸汽", "视频"], templateKey: "coffee-cup-entry", usageCount: "2.8k", }, { accent: "linear-gradient(135deg,#d3e5ff,#9fb3cc)", author: "运镜模板", badge: "热门", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。", duration: "6s", id: "orbit-360-showcase", name: "360旋转展示", steps: ["主体图", "环绕镜头", "景深", "视频"], templateKey: "orbit-360-showcase", usageCount: "5.1k", }, { accent: "linear-gradient(135deg,#d7d8dc,#737d89)", author: "工业镜头", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png", description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。", duration: "5s", id: "robot-arm-pov", name: "机械臂视角", steps: ["参考图", "机械臂", "穿行", "视频"], templateKey: "robot-arm-pov", usageCount: "1.9k", }, { accent: "linear-gradient(135deg,#f9c8d8,#c693ff)", author: "人物动画", badge: "NEW", category: "人物动画", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png", description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。", duration: "4s", id: "live-2d-character", name: "Live 2D", steps: ["角色图", "表情", "轻动作", "视频"], templateKey: "live-2d-character", usageCount: "6.4k", }, { accent: "linear-gradient(135deg,#bad5ff,#2e375d)", author: "电影运镜", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。", duration: "5s", id: "pupil-zoom", name: "瞳孔拉近", steps: ["人物图", "特写", "反光", "转场"], templateKey: "pupil-zoom", usageCount: "2.2k", }, { accent: "linear-gradient(135deg,#f7e6b0,#ef6b62)", author: "创意玩法", badge: "热门", category: "人物动画", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。", duration: "6s", id: "bird-dissolve", name: "飞鸟解体", steps: ["主体图", "粒子", "重组", "视频"], templateKey: "bird-dissolve", usageCount: "3.4k", }, { accent: "linear-gradient(135deg,#f3db93,#ef5e50)", author: "电商模板库", badge: "商用", category: "电商展示", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。", duration: "5s", id: "product-hero-entry", name: "商品震撼登场", steps: ["产品图", "冲击波", "高光", "视频"], templateKey: "product-hero-entry", usageCount: "4.8k", }, { accent: "linear-gradient(135deg,#a6e7d8,#6da3ff)", author: "旅拍模板", category: "镜头运动", cover: "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。", duration: "5s", id: "travel-whip-transition", name: "旅拍转场", steps: ["首帧", "遮挡", "转场", "视频"], templateKey: "travel-whip-transition", usageCount: "2.6k", }, ]; const ROLE_LIBRARY_FILTER_GROUPS: Array<{ key: RoleLibraryFilterKey; label: string; options: string[]; }> = [ { key: "gender", label: "性别", options: ["男", "女", "中性"] }, { key: "age", label: "年龄段", options: ["儿童", "少年", "青年", "中年", "老年"] }, { key: "species", label: "种族", options: ["人类", "精灵", "兽人", "机械", "其他"] }, { key: "era", label: "时代", options: ["先秦", "古代", "近代", "现代", "未来"] }, { key: "culture", label: "文化区域", options: ["东亚", "欧美", "中东", "非洲", "奇幻"] }, { key: "build", label: "体型", options: ["瘦削", "匀称", "健壮", "丰满"] }, { key: "hairColor", label: "发色", options: ["黑发", "白发", "金发", "棕发", "彩色"] }, ]; const ROLE_LIBRARY_APPLY_VIEWPORT = { x: -3842.91, y: 487.096, zoom: 0.743079 }; const ROLE_LIBRARY_NODE_LAYOUT = [ { height: 350, imageKey: "full" as const, title: "角色立绘", width: 282, x: 5748, y: -564 }, { height: 350, imageKey: "face" as const, title: "脸部近景", width: 282, x: 6048, y: -564 }, { height: 350, imageKey: "expression" as const, title: "表情参考", width: 282, x: 6348, y: -564 }, { height: 495, imageKey: "threeView" as const, title: "三视图", width: 878, x: 5748, y: -156 }, ]; const MATERIAL_STYLE_NODE_SIZE = 242; const MATERIAL_STYLE_SCREEN_ANCHOR = { x: 666, y: 776 }; const VIDEO_ROLE_AGREEMENT_STORAGE_KEY = "video-canvas:seedance-role-agreement:v1"; const VIDEO_MOTION_CUSTOM_STORAGE_KEY = "video-canvas:custom-motions:v1"; const VIDEO_MOTION_FAVORITES_STORAGE_KEY = "video-canvas:favorite-motions:v1"; type VideoCanvasCustomMotionPreset = VideoCanvasMotionPreset & { custom: true; }; const VIDEO_COUNT_OPTIONS = ["1个", "2个", "4个"] as const; const VIDEO_AUDIO_OPTIONS = ["开启", "关闭"] as const; const VIDEO_EFFECT_PRESETS = [ { title: "燃烧开场", author: "算法织梦者", count: 57 }, { title: "巨人俯瞰", author: "数据星云", count: 114 }, { title: "星尘降临", author: "代码极光", count: 235 }, { title: "星云漩涡", author: "算力潮汐", count: 57 }, { title: "水下慢镜头", author: "算法织梦者", count: 82 }, { title: "控雨术", author: "数据星云", count: 168 }, { title: "穿云而入", author: "代码极光", count: 93 }, { title: "双人对打", author: "算力潮汐", count: 211 }, { title: "饰品特写", author: "算法织梦者", count: 74 }, ] as const; const 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: "characterStudio", label: "角色造型室", icon: Smile }, { id: "history", label: "历史记录", icon: Clock3 }, { id: "shortcuts", label: "快捷键", icon: Keyboard }, { id: "tutorial", label: "教程", icon: BookOpen }, ]; const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 }; const PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 }; const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 }; const CANVAS_EXTENT = 20000; const CANVAS_MIN_ZOOM = 0.1; const CANVAS_MAX_ZOOM = 8; const CANVAS_ZOOM_STEP = 0.2; const ASSET_MANAGER_DEFAULT_WIDTH = 240; const ASSET_MANAGER_MIN_WIDTH = 240; const ASSET_MANAGER_MAX_WIDTH = 520; const OFFICIAL_NODE_GAP = 100; const TOOLBOX_TEMPLATE_VIEWPORT = { x: 137.4, y: 529, zoom: 0.1 }; const TOOLBOX_TEMPLATE_GROUP_FRAME = { height: 658, width: 1493, x: 6010, y: -1440, }; const OFFICIAL_TEXT_RESOURCE_HEIGHT = 330; const OFFICIAL_TEXT_RESOURCE_WIDTH = 560; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; const OFFICIAL_COMPOSER_SCALE = 0.73; const OFFICIAL_DROPDOWN_SCALE = 0.7; const OFFICIAL_NINE_GRID_WIDTH = 930; const OFFICIAL_NINE_GRID_HEIGHT = 520; const LOCAL_EDIT_MAX_REGIONS = 8; const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 192; const LOCAL_EDIT_COLORS = [ "#ff3b30", "#1677ff", "#00c853", "#ff9500", "#af52de", "#00bcd4", "#ff2d55", "#8d6e63", ]; const VIDEO_CLIP_FRAME_COUNT = 9; const VIDEO_CROP_OUTPUT_LABEL = "1024 × 576"; const VIDEO_CROP_RECT = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }; const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4"; const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid"; const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25"; function clampCanvasZoom(value: number) { return Math.min(CANVAS_MAX_ZOOM, Math.max(CANVAS_MIN_ZOOM, Number(value.toFixed(2)))); } function clampAssetManagerWidth(value: number) { return Math.min(ASSET_MANAGER_MAX_WIDTH, Math.max(ASSET_MANAGER_MIN_WIDTH, Math.round(value))); } function arrangeNodesForPreview(nodes: CloneNode[]) { if (nodes.length <= 1) return nodes; const orderedNodes = [...nodes].sort((first, second) => first.y - second.y || first.x - second.x); const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length * 1.2))); const cellWidth = Math.max(360, ...nodes.map((node) => node.width)) + OFFICIAL_NODE_GAP; const cellHeight = Math.max(260, ...nodes.map((node) => node.height)) + OFFICIAL_NODE_GAP; const arrangedById = new Map>(); orderedNodes.forEach((node, index) => { const column = index % columns; const row = Math.floor(index / columns); arrangedById.set(node.id, { x: column * cellWidth, y: row * cellHeight, }); }); return nodes.map((node) => { const arranged = arrangedById.get(node.id); return arranged ? { ...node, ...arranged } : node; }); } const CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY = "cinematic-lighting-correction"; const CHARACTER_THREE_VIEW_FUNCTION_KEY = "character-three-view"; const FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY = "frame-prediction-plus-3s"; const FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY = "frame-prediction-minus-5s"; const PROMPT_COMPOSER_BASE_WIDTH = 895; const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PRESET_COMPOSER_BASE_HEIGHT = 238; const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [ { fit: "contain", objectPosition: "50% 50%", scale: 0.62 }, { fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 }, { fit: "contain", objectPosition: "68% 50%", scale: 0.86, translateX: -18 }, { objectPosition: "34% 24%", scale: 1.4, translateX: 8, translateY: 8 }, { objectPosition: "52% 28%", scale: 1.55, translateY: 5 }, { objectPosition: "66% 26%", scale: 1.72, translateX: -8, translateY: 2 }, { objectPosition: "20% 72%", scale: 2.08, translateX: 22, translateY: -12 }, { objectPosition: "52% 68%", scale: 1.24, translateY: -4 }, { objectPosition: "70% 64%", scale: 1.46, translateX: -14, translateY: -6 }, ]; const TARGET_AVATAR = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg"; const PUBLISH_MODAL_BG_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png"; const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; const AUDIO_TO_VIDEO_PROMPT = "根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"; const TEXT_TO_VIDEO_PROMPT = "爆款情绪短片\n傍晚的城市天台,一个年轻人迎着晚风张开双手,镜头从远景缓慢推进到中景,头发被风吹起,夕阳逆光,光晕明显,空气中有漂浮微尘,电影级色彩分级,浅景深,情绪张力强,慢动作"; const TEXT_TO_VIDEO_GENERATE_PROMPT = "根据文字描述生成视频。"; const TEXT_TO_MUSIC_PROMPT = "生成一首现代品牌电子音乐,干净有力的低频贝斯,清晰电子鼓点,整体风格高级、未来感强。"; const IMAGE_TO_PROMPT_PROMPT = "根据图片生成结构化中文提示词,包括主体描述、环境、光影、镜头语言、风格关键词。"; const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。"; const VIDEO_FIRST_LAST_PROMPT = "手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然"; const SCRIPT_CHARACTER_PROMPT = "根据角色参考图生成分镜脚本,围绕旧城区、机械修复、暴雨停电和被重新点亮的街道展开,突出人物关系、镜头画面和声音设计。"; const SLASH_IMAGE_ITEMS = [ "剧情推演四宫格", "25宫格连贯分镜", "电影级光影校正", "角色三视图生成", "画面推演-3秒后", "画面推演-5秒前", ]; const STORY_SCRIPT_TEXT = `《我在盛唐写天下》 类型:古风 / 穿越 / 爽文漫剧 时长建议:60-90秒 基调:热血 x 盛唐史诗感 x 爽点节奏 【序幕】 【现代 · 深夜办公室】 键盘声急促。电脑屏幕蓝光刺眼。沈昭昭(女,28岁),面色苍白,伏案加班。手机提示音不断。她抬头,视线模糊,黑屏。 【第一幕】 【盛唐 · 金銮殿 · 日】 鼓声震天。沈昭昭猛然睁眼,已跪在大殿中央,身着男装官服,双手被押。百官列立,殿宇巍峨。 大臣厉声:“大逆不道!假称诗才惊世,欺君罔上,当斩!” 沈昭昭震惊低语:“这是……唐朝?” 龙椅之上,皇帝目光沉沉。 皇帝:“既言才华盖世,当殿作诗。若不能,斩。” 刀光映入她瞳孔。大殿死寂。 【第二幕】 沈昭昭缓缓起身。呼吸渐稳。她望向殿外,长安城阳光万里。 她低声:“既来盛唐……便与盛唐争一争。” 抬头,高声吟诵:“君不见黄河之水天上来。” 声音回荡大殿。群臣震动。皇帝缓缓站起。诗声激昂,如惊雷落殿。 【第三幕】 殿外风起。长安街市灯火连绵。 皇帝低声:“此诗,当传万世。” 沈昭昭目光坚定。她轻声自语:“既然来了……那便写尽三万里长安。” 镜头推远。盛唐山河展开。`; const DEFAULT_STORY_SCRIPT_ROWS: StoryScriptRow[] = [ { id: "story-fallback-01", shot: "01", duration: "5s", scene: "现代办公室", sceneDescription: "深夜加班,键盘声急促,人物体力透支,电脑蓝光映在苍白脸上。", shotSize: "中景", lighting: "冷蓝灰低饱和,屏幕蓝光照亮脸部,压抑疲惫", dialogue: "+", backgroundMusic: "低频悬疑氛围", voiceAndSfx: "环境键盘声、手机提示音", cameraMovement: "缓慢推镜头", imagePrompt: "现代办公室深夜加班,年轻女性伏案,电脑蓝光,疲惫神情,电影级光影,近景", motionPrompt: "中近景缓慢推进,蓝光闪烁,人物抬头后画面黑屏转场。", keyframeUrl: "", }, { id: "story-fallback-02", shot: "02", duration: "6s", scene: "盛唐大殿", sceneDescription: "女主惊醒跪于金銮殿,百官压迫,刀光映入瞳孔,皇帝俯视审判。", shotSize: "全景", lighting: "强烈暖金主光,庄严压迫", dialogue: "既言才华盖世,当殿作诗。若不能--斩。", backgroundMusic: "鼓声与低频", voiceAndSfx: "朝堂回声、刀刃声", cameraMovement: "快速摇镜头,从下往上展现大殿", imagePrompt: "盛唐金銮殿,女主男装官服跪在殿中,百官列立压迫感,皇帝龙椅俯视,史诗电影感", motionPrompt: "低角度仰拍女主,再切皇帝俯视,镜头压迫式前推。", keyframeUrl: "", }, { id: "story-fallback-03", shot: "03", duration: "6s", scene: "长安街市", sceneDescription: "诗声落下,群臣震动,长安街市灯火连绵,盛唐山河展开。", shotSize: "大远景", lighting: "暖色夕光,极度开阔明亮", dialogue: "既然来了……那便写尽三万里长安。", backgroundMusic: "史诗音乐起", voiceAndSfx: "风声、街市人声、诗句回响", cameraMovement: "极速拉高、后退,展露全貌", imagePrompt: "盛唐长安街市灯火连绵,宫殿与山河远景展开,女主背影坚定,宏大史诗感,暖色夕光", motionPrompt: "镜头从人物背影快速拉远,展示长安城与山河全景。", keyframeUrl: "", }, ]; function normalizeCanvasTitle(title?: string | null) { const value = String(title || "").trim(); if (!value || value === "未命名视频画布") return "未命名"; return value; } function getToolType(tool?: CapturedTool) { return String(tool?.type || "video").trim() || "video"; } function getToolLabel(tool?: CapturedTool) { return String(tool?.modelName || tool?.scene || tool?.modelKey || tool?.toolKey || "未命名模块").trim(); } function getToolModes(tool?: CapturedTool) { return (tool?.modes || []).map(String).filter(Boolean).slice(0, 4); } function getToolProperties(tool?: CapturedTool) { const properties = tool?.metadata?.properties; if (!properties || typeof properties !== "object") return []; return Object.entries(properties).filter(([key]) => key !== "modeType"); } function getToolRules(tool?: CapturedTool) { const rules = tool?.rules || tool?.metadata?.rules; return Array.isArray(rules) ? rules : []; } function requiresMedia(tool?: CapturedTool) { return getToolRules(tool).some((rule) => { if (!rule || typeof rule !== "object") return false; const required = (rule as { require?: unknown }).require; return Array.isArray(required) && required.map(String).includes("media"); }); } function getToolFunctionKey(tool?: CapturedTool) { const config = tool?.metadata?.config; const configuredKey = config && typeof config === "object" && !Array.isArray(config) ? (config as Record).functionKey : undefined; return String(configuredKey || tool?.toolKey || "").trim(); } function getToolRoute(tool?: CapturedTool) { const config = tool?.metadata?.config; const configuredRoute = config && typeof config === "object" && !Array.isArray(config) ? (config as Record).route : undefined; return String(configuredRoute || tool?.metadata?.route || "").trim(); } function getPropertyLabel(key: string, config: unknown) { if (config && typeof config === "object" && "displayName" in config) { const value = (config as { displayName?: unknown }).displayName; if (typeof value === "string" && value.trim()) return value; } const labels: Record = { count: "生成数量", duration: "时长", enableSound: "生成音效", frameRate: "帧率", instrumental: "纯音乐", lora: "风格", prompt: "提示词", quality: "清晰度", ratio: "比例", resolution: "分辨率", searchable: "联网搜索", search_enabled: "联网搜索", slowMotion: "慢动作", slowmo: "减速强度", specifiedModel: "模型", stability: "稳定性", style: "模型", template: "风格", voice: "声音", }; return labels[key] || key; } function getOptions(config: unknown) { if (Array.isArray(config)) return config; if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) { return (config as { enum: unknown[] }).enum; } return []; } function getOptionValue(option: unknown) { if (option && typeof option === "object" && "value" in option) { return String((option as { value?: unknown }).value ?? ""); } return String(option ?? ""); } function getOptionLabel(option: unknown) { if (option && typeof option === "object" && "displayName" in option) { const label = (option as { displayName?: unknown }).displayName; if (label) return String(label); } return getOptionValue(option); } function getDefaultValue(key: string, config: unknown) { if (config && typeof config === "object" && "default" in config) { return String((config as { default?: unknown }).default ?? ""); } const options = getOptions(config); if (options.length > 0) return getOptionValue(options[0]); if (typeof config === "boolean") return config ? "1" : "0"; if (key === "prompt") return ""; return ""; } function getInspectorTitle(node: CloneNode) { if (node.kind !== "input") return node.title; if (node.inputType === "video") return "视频输入"; if (node.inputType === "audio") return "音频输入"; if (node.inputType === "text") return "文本输入"; return "图片输入"; } function makeId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } function serializeNodes(nodes: CloneNode[]) { return nodes.map((node) => ({ id: node.id, type: "workflow", position: { x: node.x, y: node.y }, size: { width: node.width, height: node.height }, data: { title: node.title, description: node.subtitle || "", status: "pending", templateType: `libtv-${node.kind}`, metadata: { liblibCloneNode: node, }, }, connections: [], })); } function serializeEdges(edges: CloneEdge[]) { return edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, type: "default", })); } function restoreNodes(canvasData: unknown): CloneNode[] { const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes; if (!Array.isArray(nodes)) return []; return nodes .map((item, index) => { const metadata = ((item as any)?.data?.metadata || {}) as Record; const clone = metadata.liblibCloneNode as CloneNode | undefined; return clone && typeof clone.id === "string" ? normalizeRestoredNode(clone, index + 1) : null; }) .filter(Boolean) as CloneNode[]; } function restoreEdges(canvasData: unknown): CloneEdge[] { const edges = (canvasData as { edges?: unknown[] } | null)?.edges; if (!Array.isArray(edges)) return []; return edges .map((edge) => ({ id: String((edge as any).id || makeId("edge")), source: String((edge as any).source || ""), target: String((edge as any).target || ""), })) .filter((edge) => edge.source && edge.target); } function createInputNode(inputType: CloneNode["inputType"], x: number, y: number): CloneNode { const title = inputType === "video" ? "上传视频" : inputType === "audio" ? "上传音频" : inputType === "text" ? "输入文本" : "上传图片"; return { id: makeId("input"), kind: "input", title, subtitle: inputType === "video" ? "添加视频素材、参考视频或首尾帧" : inputType === "audio" ? "添加音乐、语音或音效参考" : inputType === "text" ? "输入脚本、分镜或提示词" : "添加参考图、角色图或关键帧素材", inputType, x, y, width: 158, height: 158, accent: "#8790ff", }; } function createToolNode(tool: CapturedTool, x: number, y: number): CloneNode { const type = getToolType(tool); const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; const isAudio = type === "audio"; const isText = type === "text"; const toolConfig = tool.metadata?.config; const draftValues = toolConfig && typeof toolConfig === "object" && !Array.isArray(toolConfig) ? { ...toolConfig } : {}; return { id: makeId("tool"), kind: isAudio ? "audio" : isText ? "text" : "model", title: getToolLabel(tool), subtitle: tool.vendor || meta.label, x, y, width: isText ? 210 : 194, height: isText ? 122 : 116, accent: meta.accent, tool, draftValues, }; } function createWorkflowNode(workflow: WorkflowPreset, x: number, y: number): CloneNode { return { id: makeId("workflow"), kind: "workflow", title: String(workflow.name || workflow.title || "工作流预设"), subtitle: workflow.description || "工作流预设", x, y, width: 230, height: 116, accent: "#8b96ff", workflow, draftValues: {}, }; } function createToolboxTemplateGroupNode(title: string): CloneNode { return { id: makeId("group"), kind: "workflow", title: "分组 3 个节点", subtitle: title, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x, y: TOOLBOX_TEMPLATE_GROUP_FRAME.y, width: TOOLBOX_TEMPLATE_GROUP_FRAME.width, height: TOOLBOX_TEMPLATE_GROUP_FRAME.height, accent: "#ffffff", draftValues: { action: "TOOLBOX_TEMPLATE_GROUP", nodeType: "group", templateName: title, }, }; } function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: number): CloneNode { return { id: makeId(kind), kind, title, subtitle: kind === "preview" ? "预览结果" : kind === "save" ? "保存到本地" : "输入内容", x, y, width: kind === "text" ? 210 : 194, height: kind === "text" ? 122 : 116, accent: kind === "save" || kind === "preview" ? "#8b96ff" : "#f0d06b", inputType: kind === "text" ? "text" : undefined, draftValues: {}, }; } function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode { if (kind === "script") { return { id: makeId("script"), kind: "script", title: "脚本生成器", subtitle: "脚本生成", inputType: "text", x, y, width: 350, height: 350, accent: "#f0d06b", draftValues: { action: "SCRIPT_GENERATE", generatorType: "DEFAULT", nodeType: "script-v2", rows: [], scriptRole: "generator", viewMode: "card", }, }; } if (kind === "legacy-script") { return { id: makeId("script"), kind: "script", title: "脚本生成器", subtitle: "脚本生成", inputType: "text", x, y, width: 350, height: 350, accent: "#f0d06b", draftValues: { action: "SCRIPT_GENERATE", generatorType: "DEFAULT", nodeType: "legacy-script", rows: [], scriptRole: "generator", viewMode: "card", }, }; } if (kind === "smart-edit") { return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:350,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit",smartEditDraft:{mode:"auto",ratio:"16:9",resolution:"720p",targetDuration:30}}}; } if (kind === "frame-analysis") { return { id: makeId("frame-analysis"), kind: "model", title: "逐帧拉片", subtitle: "视频解析", x, y, width: 350, height: 350, accent: "#67a9bc", draftValues: { action: "FRAME_ANALYSIS", nodeType: "frame-analysis", dimensions: { shots: true, motion: true, music: true } } }; } if (kind === "video-clip") { return { id: makeId("video-clip"), kind: "model", title: `视频合成 ${index}`, subtitle: "视频合成", inputType: "video", x, y, width: 350, height: 350, accent: "#ffb8c8", draftValues: { action: "VIDEO_CLIP_RESOURCE", nodeType: "video-clip", params: {}, url: [], }, }; } if (kind === "director") { return { id: makeId("director"), kind: "model", title: `导演台 ${index}`, subtitle: "3D导演台", x, y, width: 350, height: 350, accent: "#9bd6ff", draftValues: { action: "DIRECTOR_CONSOLE_3D", consoleType: "director-console-3d", nodeType: "director", params: { activeCamera: "机位1", fov: 50, }, }, }; } const meta: Record, { action: string; accent: string; kind: CloneNodeKind; nodeType: NonNullable; subtitle: string; title: string; }> = { audio: { action: "AUDIO_GENERATE", accent: "#9ee6bb", kind: "audio", nodeType: "audio", subtitle: "音频生成", title: `音频节点 ${index}`, }, image: { action: "IMAGE_GENERATE", accent: "#8b96ff", kind: "model", nodeType: "image", subtitle: "图片生成", title: `图片节点 ${index}`, }, text: { action: "TEXT_GENERATE", accent: "#f0d06b", kind: "text", nodeType: "text", subtitle: "文本生成", title: `文本节点 ${index}`, }, video: { action: "VIDEO_GENERATE", accent: "#8b96ff", kind: "model", nodeType: "video", subtitle: "视频生成", title: `视频节点 ${index}`, }, }; const item = meta[kind]; const isSquareModule = item.nodeType === "text" || item.nodeType === "image" || item.nodeType === "audio"; const isVideoModule = item.nodeType === "video"; return { id: makeId(kind), kind: item.kind, title: item.title, subtitle: item.subtitle, inputType: item.nodeType, x, y, width: isSquareModule ? 350 : isVideoModule ? 622 : 436, height: isSquareModule || isVideoModule ? 350 : 470, accent: item.accent, draftValues: { action: item.action, alt: item.nodeType === "image" ? `图片 ${index}` : undefined, content: item.nodeType === "text" ? [] : undefined, generatorType: "DEFAULT", nodeType: item.nodeType, poster: item.nodeType === "video" ? "" : undefined, url: item.nodeType === "text" ? undefined : [], }, }; } function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode { const mediaType: NonNullable = file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "image"; const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; const fallbackName = `${label}节点 ${index}`; const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName; return { id: makeId(mediaType), kind: "media", title, subtitle: `${label}资源`, inputType: mediaType, x, y, width: mediaType === "audio" ? 430 : 420, height: mediaType === "audio" ? 170 : 280, accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", draftValues: { action: `${mediaType.toUpperCase()}_RESOURCE`, alt: file.name, generated: false, mediaType, mediaUrl, nodeType: mediaType, resourceMeta: { items: [{ name: file.name, size: file.size, type: file.type }], }, url: [mediaUrl], }, }; } function createMediaResourceNode({ action, height, mediaType, mediaUrl, title, width, x, y, prompt, }: { action?: string; height?: number; mediaType: NonNullable; mediaUrl: string; prompt?: string; title: string; width?: number; x: number; y: number; }): CloneNode { const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : mediaType === "text" ? "文本" : "图片"; return { id: makeId(mediaType), kind: "media", title, subtitle: `${label}资源`, inputType: mediaType, x, y, width: width ?? (mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420), height: height ?? (mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280), accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", draftValues: { action: action ?? `${mediaType.toUpperCase()}_RESOURCE`, alt: title, generated: false, mediaType, mediaUrl, nodeType: mediaType, params: prompt ? { prompt } : undefined, prompt: prompt || "", url: [mediaUrl], }, }; } function createMaterialStyleNode({ author, count, model, thumbnailUrl, title, x, y, }: { author: string; count: number; model?: string; thumbnailUrl: string; title: string; x: number; y: number; }): CloneNode { return { id: makeId("style"), kind: "material-style", title: `素材-风格-${title}`, subtitle: "风格素材", inputType: undefined, 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], }, }; } const HISTORY_ASSET_ZOOM_LEVELS = [ { cardSize: 130, label: "75%" }, { cardSize: 144, label: "100%" }, { cardSize: 182, label: "125%" }, { cardSize: 248, label: "150%" }, ] as const; function createNineGridCompositeNode({ anchor, columns, functionKey = MULTI_VIEW_NINE_GRID_FUNCTION_KEY, gridPrompt, option, rows, sourceUrl, x, y, }: { anchor: CloneNode; columns: number; functionKey?: string; gridPrompt?: string; option?: string; rows: number; sourceUrl: string; x: number; y: number; }): CloneNode { const cellCount = rows * columns; const title = cellCount === 9 ? "多机位九宫格" : option?.trim() || `${cellCount}宫格`; const resolvedGridPrompt = gridPrompt?.trim() || (cellCount === 9 && (!option || /多|9|九/.test(option)) ? getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY) : `基于选中图片生成 ${columns}x${rows} 宫格分镜图,使用黑色分割线分隔每一格。`); return { id: makeId("image"), kind: "media", title, subtitle: "生成结果", inputType: "image", x, y, width: OFFICIAL_NINE_GRID_WIDTH, height: OFFICIAL_NINE_GRID_HEIGHT, accent: "#8790ff", draftValues: { action: "IMAGE_GENERATE_NINE_GRID", functionKey, generated: true, gridColumns: columns, gridPrompt: resolvedGridPrompt, gridRows: rows, mediaType: "image", nineGridCells: Array.from({ length: cellCount }).map((_, index) => getGridCellVisual(index)), nineGridComposite: true, nodeType: "image", outputSizeLabel: "2752 × 1536", params: { columns, functionKey, mode: "grid", option: option || "", prompt: resolvedGridPrompt, rows, sourceNodeId: anchor.id, sourceUrl, }, prompt: gridPrompt, sourceNodeId: anchor.id, sourceUrl, toolbarOption: option || "", url: [], }, }; } function createTextResourceNode({ content, height = OFFICIAL_TEXT_RESOURCE_HEIGHT, title, width = OFFICIAL_TEXT_RESOURCE_WIDTH, x, y, }: { content: string; height?: number; title: string; width?: number; x: number; y: number; }): CloneNode { return { id: makeId("text"), kind: "script", title, subtitle: "文本资源", inputType: "text", x, y, width, height, accent: "#f0d06b", draftValues: { action: "TEXT_RESOURCE", content: [content], nodeType: "text", scriptRole: "resource", viewMode: undefined, }, }; } function getDraftString(node: CloneNode | null | undefined, key: string) { const value = node?.draftValues?.[key]; return typeof value === "string" ? value : ""; } function isVideoEffectMaterialNode(node: CloneNode | null | undefined) { return Boolean( node && (node.kind === "material-effect" || getDraftString(node, "action") === "MATERIAL_EFFECT"), ); } function isVideoRoleMaterialNode(node: CloneNode | null | undefined) { return Boolean( node && (node.kind === "material-role" || getDraftString(node, "action") === "MATERIAL_ROLE"), ); } function getDraftNumber(node: CloneNode | null | undefined, key: string, fallback = 0) { const value = node?.draftValues?.[key]; const numericValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } function getNodeMediaUrl(node: CloneNode | null | undefined) { const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); if (direct) return direct; const urls = node?.draftValues?.url; return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : ""; } function preventNativeImageDrag(event: ReactDragEvent) { event.preventDefault(); } function normalizeGridOption(option?: string) { const text = String(option || "").trim(); const explicitGrid = text.match(/(\d+)\s*[x×]\s*(\d+)/i); if (explicitGrid) { const columns = Math.max(1, Math.min(5, Number.parseInt(explicitGrid[1] || "3", 10) || 3)); const rows = Math.max(1, Math.min(5, Number.parseInt(explicitGrid[2] || "3", 10) || 3)); return { columns, rows, tileCount: columns * rows }; } const tileCount = /25\s*宫格|5\s*[x×]\s*5|25/.test(text) ? 25 : /16\s*宫格|4\s*[x×]\s*4|16/.test(text) ? 16 : /4\s*宫格|2\s*[x×]\s*2|四/.test(text) ? 4 : 9; const columns = tileCount === 4 ? 2 : tileCount === 16 ? 4 : tileCount === 25 ? 5 : 3; const rows = Math.ceil(tileCount / columns); return { columns, rows, tileCount }; } function getGridCellVisual(index: number): GridCellVisual { return NINE_GRID_CELL_VISUALS[index % NINE_GRID_CELL_VISUALS.length]; } function getNodeSourceMediaUrl(node: CloneNode | null | undefined) { return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node); } function readyCanvasInputs(nodes: CloneNode[], edges: CloneEdge[], target: string) { try { const incoming = connectedInputNodes(nodes, edges, target); const targetNode = nodes.find(node => node.id === target); if (targetNode) for (const source of incoming) { const error = connectionTypeError(source, targetNode); if (error) throw new Error(error); } assertConnectedInputsReady(incoming); return incoming; } catch (error) { toast.error(error instanceof Error ? error.message : "上游输入不可用"); return null; } } function getNodeReferenceMediaUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl"); } function getNodePlayableVideoUrl(node: CloneNode | null | undefined) { const direct = getDraftString(node, "mediaUrl"); if (direct) return direct; const urls = node?.draftValues?.url; return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : ""; } function normalizeVideoCanvasMagicMarks(value: unknown): VideoCanvasMagicMark[] { if (!Array.isArray(value)) return []; return value .map((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const record = item as Record; const sourceNodeId = String(record.sourceNodeId || "").trim(); const imageUrl = String(record.imageUrl || "").trim(); const labelOptions = Array.from( new Set( (Array.isArray(record.labelOptions) ? record.labelOptions : [record.label]) .map((label) => String(label || "").trim().slice(0, 8)) .filter(Boolean) ) ).slice(0, 4); if (!sourceNodeId || !imageUrl || labelOptions.length === 0) return null; const clickX = Math.max(0, Math.min(1, Number(record.clickX) || 0)); const clickY = Math.max(0, Math.min(1, Number(record.clickY) || 0)); const selectedIndex = Math.max( 0, Math.min(labelOptions.length - 1, Number(record.selectedIndex) || 0) ); return { clickX, clickY, id: String(record.id || `${sourceNodeId}_${labelOptions[0]}`).trim(), imageUrl, labelOptions, selectedIndex, sourceNodeId, } satisfies VideoCanvasMagicMark; }) .filter((item): item is VideoCanvasMagicMark => Boolean(item)); } function getVideoCanvasMagicMarks(node: CloneNode | null | undefined) { if (!node) return []; const params = getNodeParams(node); return normalizeVideoCanvasMagicMarks( node.draftValues?.magicMarks || params.magicMarks ); } function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node); } function isVideoMediaNode(node: CloneNode | null | undefined): node is CloneNode { if (!node || node.kind !== "media") return false; const mediaType = getDraftString(node, "mediaType") || node.inputType || ""; return mediaType === "video"; } function getVideoNodePreviewUrl(node: CloneNode | null | undefined) { return getDraftString(node, "poster") || getNodeSourceMediaUrl(node) || getNodeMediaUrl(node); } function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) { const meta = node?.draftValues?.videoMeta as Record | undefined; const recordedDuration = Number(meta?.durationSeconds || node?.draftValues?.durationSeconds || meta?.duration); if (Number.isFinite(recordedDuration) && recordedDuration > 0) return recordedDuration; const params = getNodeParams(node); const paramsSettings = params.settings && typeof params.settings === "object" ? (params.settings as Record) : {}; const numericValue = Number( params.duration || paramsSettings.duration || getDraftString(node, "duration") || (node?.draftValues?.videoMeta && typeof node.draftValues.videoMeta === "object" ? (node.draftValues.videoMeta as Record).duration : 0) ); if (Number.isFinite(numericValue) && numericValue > 0) return numericValue; const outputLabel = getDraftString(node, "outputSizeLabel"); const durationMatch = outputLabel.match(/(\d+(?:\.\d+)?)\s*s/i); if (durationMatch) { const parsed = Number(durationMatch[1] || "0"); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return 5; } function getVideoCropSelectionLabel(node: CloneNode) { const rect = (node.draftValues?.cropRect || VIDEO_CROP_RECT) as GridCropRect; const dimensions = getDraftString(node, "outputSizeLabel").match(/(\d+)\s*[×x]\s*(\d+)/); if (!dimensions) return `裁剪区域 ${Math.round(rect.width * 100)}% × ${Math.round(rect.height * 100)}%`; return `${Math.floor(Number(dimensions[1]) * rect.width / 2) * 2} × ${Math.floor(Number(dimensions[2]) * rect.height / 2) * 2}`; } function formatClipDurationLabel(seconds: number) { const normalized = Math.max(0.1, seconds); return `${normalized.toFixed(2)} s`; } function formatVideoTimestamp(seconds: number) { const normalized = Math.max(0, Math.round(seconds)); const minutes = Math.floor(normalized / 60); const remainder = normalized % 60; return `${minutes}:${String(remainder).padStart(2, "0")}`; } function formatAudioTimestamp(seconds: number) { const normalized = Math.max(0, Math.round(seconds)); const minutes = Math.floor(normalized / 60); const remainder = normalized % 60; return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`; } function normalizeVideoClipRange(value: unknown): VideoClipRange { if (!value || typeof value !== "object") return { start: 0.18, end: 0.52 }; const candidate = value as Partial; const start = Number(candidate.start); const end = Number(candidate.end); if (!Number.isFinite(start) || !Number.isFinite(end)) { return { start: 0.18, end: 0.52 }; } return { start: Math.max(0, Math.min(start, 0.94)), end: Math.max(Math.min(start + 0.04, 1), Math.min(end, 1)), }; } function createVideoStoryAnalysisNode({ existingNode, framePreviews = [], sourceNode, rows, summary, error, }: { error?: string; existingNode?: CloneNode; framePreviews?: string[]; sourceNode: CloneNode; rows: VideoAnalysisRow[]; summary: string; }): CloneNode { const previewUrl = getVideoNodePreviewUrl(sourceNode) || ""; return { id: existingNode?.id || makeId("video-story"), kind: "video-story", title: "视频故事", subtitle: "视频解析结果", inputType: "text", x: existingNode?.x ?? Math.round(sourceNode.x - 720), y: existingNode?.y ?? Math.round(sourceNode.y + sourceNode.height + 620), width: existingNode?.width ?? 800, height: existingNode?.height ?? 400, accent: "#8b96ff", draftValues: { ...(existingNode?.draftValues || {}), action: "VIDEO_STORY_ANALYSIS", error: error || undefined, framePreviews, generated: true, keyframeUrl: previewUrl, mediaType: "text", nodeType: "video-story", rows, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, status: error ? "error" : "ready", summary, }, }; } function normalizeVideoHdResolutionOption(option: string) { const text = option.trim(); if (/^(1080P|2K|4K)$/i.test(text)) return text.toUpperCase(); return "1080P"; } function createVideoHdUpscaleNode({ existingNode, option, sourceNode, }: { existingNode?: CloneNode; option: string; sourceNode: CloneNode; }): CloneNode { const resolution = normalizeVideoHdResolutionOption(option); const sourceUrl = getNodeReferenceMediaUrl(sourceNode); return { id: existingNode?.id || makeId("video-hd"), kind: "model", title: `高清(${resolution})`, subtitle: "配置参数", inputType: "video", x: existingNode?.x ?? Math.round(sourceNode.x), y: existingNode?.y ?? Math.round(sourceNode.y + sourceNode.height + OFFICIAL_NODE_GAP), width: existingNode?.width ?? 350, height: existingNode?.height ?? 350, accent: "#8b96ff", draftValues: { ...(existingNode?.draftValues || {}), action: "VIDEO_HD_UPSCALE", frameInterpolation: option === "不补帧" ? "不补帧" : existingNode?.draftValues?.frameInterpolation || "不补帧", generated: existingNode?.draftValues?.generated || false, hdEngine: option === "Real-ESRGAN" ? "Real-ESRGAN" : existingNode?.draftValues?.hdEngine || "Real-ESRGAN", hdResolution: resolution, mediaType: "video", nodeType: "video-hd", outputSizeLabel: resolution === "4K" ? "3840 × 2160" : resolution === "2K" ? "2560 × 1440" : "1920 × 1080", scale: option === "1x" ? "1x" : existingNode?.draftValues?.scale || "1x", sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, sourceUrl, url: existingNode?.draftValues?.url || [], }, }; } function createVideoCropResultNode({ existingIndex, sourceNode, }: { existingIndex: number; sourceNode: CloneNode; }): CloneNode { const sourceUrl = getNodeReferenceMediaUrl(sourceNode); const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const width = sourceNode.width || 560; const height = sourceNode.height || 315; return { id: makeId("video"), kind: "media", title: `${sourceNode.title || "视频"}_裁剪`, subtitle: "裁剪中", inputType: "video", x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y + existingIndex * (height + OFFICIAL_NODE_GAP)), width, height, accent: "#8b96ff", draftValues: { action: "VIDEO_CROP", cropRect: VIDEO_CROP_RECT, durationSeconds: getVideoNodeDurationSeconds(sourceNode), generated: false, generationProgress: 37, generationStatus: "pending", mediaType: "video", mediaUrl: "", nodeType: "video", outputSizeLabel: VIDEO_CROP_OUTPUT_LABEL, poster: previewUrl, sourceUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, uploadStatus: "uploading", url: [], }, }; } function createVideoSubtitleEraseNode({ existingIndex, mode = "smart", regions = [], sourceNode, }: { existingIndex: number; mode?: SubtitleEraseMode; regions?: SubtitleEraseRegion[]; sourceNode: CloneNode; }): CloneNode { const sourceUrl = getNodeReferenceMediaUrl(sourceNode); const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const outputSizeLabel = getDraftString(sourceNode, "outputSizeLabel") || "1280 × 720"; const normalizedRegions = regions.map(({ height, width, x, y }) => ({ height: Number(height.toFixed(4)), width: Number(width.toFixed(4)), x: Number(x.toFixed(4)), y: Number(y.toFixed(4)), })); const isRegionErase = mode === "region"; return { id: makeId("video"), kind: "media", title: `视频一键去字幕-${sourceNode.title || "视频"}`, subtitle: isRegionErase ? "框选擦除" : "去字幕", inputType: "video", x: Math.round(sourceNode.x + sourceNode.width + OFFICIAL_NODE_GAP), y: Math.round(sourceNode.y - 450 + existingIndex * 390), width: 350, height: 350, accent: "#8b96ff", draftValues: { action: "VIDEO_SUBTITLE_ERASE", cost: 0, durationSeconds: getVideoNodeDurationSeconds(sourceNode), generated: false, generationProgress: 2, generationStatus: "pending", mediaType: "video", mediaUrl: "", mode: isRegionErase ? "Text" : "Subtitle", eraseMode: mode, eraseRatioLocation: normalizedRegions, erase_ratio_location: normalizedRegions, model: "volcano-subtitle-eraser", nodeType: "video", outputSizeLabel, poster: previewUrl, sourceUrl, sourceVideoNodeId: sourceNode.id, sourceVideoTitle: sourceNode.title, url: [], }, }; } function formatStoryShot(value: unknown, index: number) { const text = String(value || "").trim(); if (/^\d+$/.test(text)) return text.padStart(2, "0"); return text || String(index + 1).padStart(2, "0"); } function normalizeStoryScriptRow(row: unknown, index: number, nodeId: string): StoryScriptRow | null { if (Array.isArray(row)) { const cells = row.map((cell) => String(cell || "").trim()); const shot = formatStoryShot(cells[0], index); const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || ""); const duration = secondCellLooksLikeDuration ? cells[1] || "5s" : "5s"; const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`; const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene; const voiceAndSfx = (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无"; const cameraMovement = (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头"; return { id: `${nodeId}-story-row-${index}`, shot, duration, scene, sceneDescription, shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景", lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影", dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+", backgroundMusic: "无", voiceAndSfx, cameraMovement, imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription, motionPrompt: (secondCellLooksLikeDuration ? cells[9] || cameraMovement : cells[6]) || "保持镜头运动自然、连贯,围绕主体动作推进。", keyframeUrl: "", }; } if (!row || typeof row !== "object") return null; const record = row as Record; if (["shot","duration","scene","sceneDescription","shotSize","lighting","dialogue","backgroundMusic","voiceAndSfx","cameraMovement","imagePrompt","motionPrompt","keyframeUrl"].every(key => typeof record[key] === "string")) { return { ...record, id: typeof record.id === "string" ? record.id : `${nodeId}-story-row-${index}` } as StoryScriptRow; } 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)); return rows; } type StoryWorkflowAsset = { id?: string; description: string; imageUrl: string; name: string; type: "role" | "scene" | "prop"; }; function getStoryWorkflowAssetGroups(rows: StoryScriptRow[]) { const names = [...new Set(rows.flatMap(row => [row.scene, row.sceneDescription, row.imagePrompt, row.motionPrompt].join(" ").match(/@[\p{L}\p{N}_-]+/gu) || []))]; const assets: StoryWorkflowAsset[] = names.map(name => ({ name: name.slice(1), description: "脚本引用,请确认类型并准备图片", imageUrl: "", type: /场景|街|城|室|厅|山|海/.test(name) ? "scene" : /刀|剑|杯|道具/.test(name) ? "prop" : "role", })); return { role: assets.filter(a => a.type === "role"), scene: assets.filter(a => a.type === "scene"), prop: assets.filter(a => a.type === "prop") }; } 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, onPatch, onStepChange, step, imageNodes, imageModel, analysisModel, taskBusy = false, taskError, onSubmitTask, }: { node: CloneNode; onClose: () => void; onRun: () => void; onPatch: (patch: Record) => void; onStepChange: (step: StoryWorkflowStep) => void; step: StoryWorkflowStep; imageNodes: CloneNode[]; imageModel?: string; analysisModel?: string; taskBusy?: boolean; taskError?: string; onSubmitTask?: ( kind: "asset" | "prompts", data: { assets: StoryWorkflowAsset[]; rows: StoryScriptRow[]; assetIndex?: number; style: string; }, ) => Promise; }) { const rows = getStoryScriptRows(node); const detected = getStoryWorkflowAssetGroups(rows); const assets = ( Array.isArray(node.draftValues?.storyAssets) ? node.draftValues.storyAssets : [...detected.role, ...detected.scene, ...detected.prop] ) as StoryWorkflowAsset[]; const style = String(node.draftValues?.storyStyle || ""); const [busy, setBusy] = useState(null); const [error, setError] = useState(""); const uploadInput = useRef(null); const uploadIndex = useRef(0); const latest = useRef({ rows, assets }); latest.current = { rows, assets }; const updateAsset = (index: number, patch: Partial) => onPatch({ storyAssets: latest.current.assets.map((asset, i) => i === index ? { ...asset, ...patch } : asset, ), }); const updateRow = (index: number, patch: Partial) => onPatch({ rows: latest.current.rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), }); const compose = async (index?: number) => { if (busy || taskBusy) return; const source = index === undefined ? rows : [rows[index]]; if (!source.length) return; setBusy("prompts"); setError(""); try { if (!onSubmitTask) throw new Error("任务服务尚未就绪"); await onSubmitTask("prompts", { assets, rows: source, style }); } catch (error) { setError(error instanceof Error ? error.message : "提示词提交失败"); } finally { setBusy(null); } }; const generateAsset = async (index: number) => { if (busy || taskBusy) return; setBusy("asset"); setError(""); try { if (!onSubmitTask) throw new Error("任务服务尚未就绪"); await onSubmitTask("asset", { assets, rows, style, assetIndex: index }); } catch (error) { setError(error instanceof Error ? error.message : "资产提交失败"); } finally { setBusy(null); } }; const field = "w-full rounded border border-white/10 bg-white/5 p-2 text-xs outline-none focus:border-white/40"; const button = "rounded-lg border border-white/10 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40"; const fields: Array<[keyof StoryScriptRow, string]> = [ ["shot", "镜号"], ["duration", "时长"], ["sceneDescription", "画面描述"], ["shotSize", "景别"], ["lighting", "光影"], ["dialogue", "对白"], ["voiceAndSfx", "音效"], ["cameraMovement", "运镜"], ["imagePrompt", "图像提示词"], ["motionPrompt", "运动提示词"], ]; const readyAssets = assets.filter((asset) => asset.imageUrl).length; const readyPrompts = rows.filter( (row) => row.imagePrompt.trim() && row.motionPrompt.trim(), ).length; return (
event.stopPropagation()} >
{node.title || "脚本工作流"}
{error && (

{error}

)} {taskError ? (

{taskError}

) : null} {taskBusy ? (

生成任务处理中,可关闭窗口,结果会自动保存并同步。

) : null} {Object.values( (node.draftValues?.storyTaskResults || {}) as Record< string, { status: string; message: string } >, ) .slice(-1) .map((result, index) => (

{result.message}

))}
{step === "assets" ? ( <> {( [ ["role", "角色"], ["scene", "场景"], ["prop", "道具"], ] as const ).map(([type, label]) => (

{label}

{assets.map((asset, index) => asset.type !== type ? null : (
{asset.imageUrl ? ( {asset.name} ) : (
尚未准备图片
)} updateAsset(index, { name: e.target.value })} />