"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 (
);
}
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) => (
))}
))}
);
const renderAssetGroup = (
title: string,
assets: StoryWorkflowAsset[],
emptyLabel: string,
) => (
{title}
{assets.map((asset) => (
{asset.name}
{asset.description}
{["替换图片", "AI 生角色", "清除图片"].map((item) => (
{item}
))}
删除
))}
);
return (
event.stopPropagation()}
>
{step === "shots" ? renderShotTable(false) : null}
{step === "assets" ? (
全局风格
盛唐·国风二次元
{renderAssetGroup("角色", assetGroups.role, "新增")}
{renderAssetGroup("场景", assetGroups.scene, "新增")}
{renderAssetGroup("道具", assetGroups.prop, "新增")}
) : null}
{step === "prompts" ? renderShotTable(true) : 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