)}
- {selectedImageNodes.length > 0 && (
+ {!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
- {selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
+ {isWorkflowCanvas && selectedImageNodes.length === 1 ? (
)}
diff --git a/components/canvas/GroupStoryboardPreview.tsx b/components/canvas/GroupStoryboardPreview.tsx
new file mode 100644
index 000000000..cdcea4b0a
--- /dev/null
+++ b/components/canvas/GroupStoryboardPreview.tsx
@@ -0,0 +1,122 @@
+"use client";
+import { useEffect, useState } from "react";
+import { ArrowLeft, ArrowRight, X } from "lucide-react";
+export type GroupShot = {
+ id: string;
+ title: string;
+ url: string;
+ type: "image" | "video";
+};
+export function GroupStoryboardPreview({
+ shots,
+ onOrder,
+ onClose,
+}: {
+ shots: GroupShot[];
+ onOrder: (ids: string[]) => void;
+ onClose: () => void;
+}) {
+ const [index, setIndex] = useState(0),
+ shot = shots[Math.min(index, shots.length - 1)];
+ useEffect(() => {
+ const key = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", key);
+ return () => window.removeEventListener("keydown", key);
+ }, [onClose]);
+ if (!shot) return null;
+ const move = (delta: number) => {
+ const next = index + delta;
+ if (next < 0 || next >= shots.length) return;
+ const ids = shots.map((s) => s.id);
+ [ids[index], ids[next]] = [ids[next], ids[index]];
+ onOrder(ids);
+ setIndex(next);
+ };
+ return (
+
e.stopPropagation()}
+ >
+
+
+ 分镜 {index + 1}/{shots.length} · {shot.title}
+
+
+
+
+
+
+ {shot.type === "video" ? (
+
+
+
setIndex((i) => i - 1)}
+ className="disabled:opacity-30"
+ >
+
+
+
+ {index + 1} / {shots.length}
+
+
setIndex((i) => i + 1)}
+ className="disabled:opacity-30"
+ >
+
+
+
move(-1)}
+ className="disabled:opacity-30"
+ >
+ 镜头前移
+
+
move(1)}
+ className="disabled:opacity-30"
+ >
+ 镜头后移
+
+
+
+ {shots.map((s, i) => (
+ setIndex(i)}
+ className={`shrink-0 rounded-lg border px-4 py-2 text-sm ${i === index ? "border-white bg-white/20" : "border-white/20"}`}
+ >
+ {i + 1} · {s.title}
+
+ ))}
+
+
+ );
+}
diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx
index 8594cec7a..58f6a79d5 100644
--- a/components/canvas/LiblibVideoCanvasReplica.tsx
+++ b/components/canvas/LiblibVideoCanvasReplica.tsx
@@ -1,11 +1,47 @@
"use client";
+import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
+import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
+import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
+import { CanvasGroupToolbar } from "./CanvasGroupToolbar";
+import { isCanvasGroup, groupMembers, makeCanvasGroup, ungroupCanvasNodes, moveCanvasSelection, layoutCanvasGroup, migrateLegacyGroups, exportCanvasGroup, instantiateCanvasGroup, groupBounds, assignGroupMembers } from "@/lib/video-canvas/groups";
+import { compileGroupRun } from "@/lib/video-canvas/group-execution";
+
+import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedText, connectedInputNodes, assertConnectedInputsReady, withConnectedText, connectedImageUrls } from "@/lib/video-canvas/connections";
+import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
+import { SmartEditDialog } from "./smart-edit/SmartEditDialog";
+import { SmartEditNodeCard, SmartEditNodeComposer, type SmartEditMode } from "./smart-edit/SmartEditNode";
+import { smartEditRequestSchema, type EditPlan } from "@/lib/video-canvas/smart-edit/contract";
+import { AudioGenerationComposer } from "./AudioGenerationComposer";
+import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel";
+import { resolveTVstarGenerationReferences } from "./tvstar/generation-references";
+import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
+import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output";
+import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel";
+import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input";
+import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
+import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
+import dynamic from "next/dynamic";
+import { signOut } from "next-auth/react";
+import { PromptTranslateButton } from "@/components/canvas/PromptTranslateButton";
+import { useVideoCanvasTasks } from "@/hooks/use-video-canvas-tasks";
+import { requireAnalysisResult } from "@/lib/video-canvas/task-contract";
+const DirectorStudioDialog = dynamic(
+ () => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog),
+ { ssr: false },
+);
+import { directorMotionPrompt, resizeDirectorMotion } from "@/lib/video-canvas/director-motion";
+import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene";
+import { chooseStoryVideoSettings, filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, isCanvasLanguageModel, isCanvasVisionModel, type CanvasModelKind } from "@/lib/video-canvas/model-matching";
import {
+ ScanText,
+ AudioLines,
ArrowUp,
Bell,
BookOpen,
BoxSelect,
Camera,
+ Clapperboard,
Check,
ChevronDown,
ChevronRight,
@@ -17,6 +53,7 @@ import {
FolderOpen,
GitBranch,
Headphones,
+ Mic,
HelpCircle,
Image as ImageIcon,
Keyboard,
@@ -36,6 +73,7 @@ import {
Scissors,
Settings2,
SlidersHorizontal,
+ Smile,
Sparkles,
Star,
Trash2,
@@ -57,6 +95,7 @@ import {
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
+ type CSSProperties,
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
@@ -65,6 +104,11 @@ import {
useState,
} from "react";
import { toast } from "sonner";
+import { ZCODE_VIDEO_CANVAS_AUTHORIZED } from "@/lib/ai/canvas-agent-runtime/authorization";
+import { CanvasAgentUiListener } from "./chat-panel/canvas-agent-ui-listener";
+import type { CanvasAgentUiCommand } from "@/lib/ai/canvas-agent-runtime/ui-command";
+import { VideoCanvasAgentPanel } from "./chat-panel/video-canvas-agent-panel";
+import { mergeVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import useSWR from "swr";
import {
getGenerationCreditCostFromImageGenConfig,
@@ -86,8 +130,17 @@ import {
canvasService,
imageGenerationService,
videoGenerationService,
- workflowAnalysisService,
} from "@/services/canvas";
+import { canvasGenerationCount, canvasImageRequest, canvasImageResultUrls } from "@/lib/video-canvas/generation-contract";
+import { anglePresets, angleRequestParameters } from "@/lib/video-canvas/angle-settings";
+import { downloadCanvasMedia } from "@/lib/video-canvas/download";
+import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result";
+import { VideoArchiveButton } from "@/components/canvas/VideoArchiveButton";
+import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog";
+import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools";
+import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence";
+import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import";
+import { getUploadedMediaNodeSize } from "@/lib/video-canvas/media-node-size";
import type { CanvasInitialProject } from "@/types/canvas";
import {
getVideoCanvasFunctionBillingCopy,
@@ -112,6 +165,8 @@ type ActivePanel =
| "historyPicker"
| "help"
| "roleLibrary"
+ | "characterStudio"
+ | "tvstar"
| "videoRoleLibrary"
| "shortcuts"
| "styleLibrary"
@@ -127,6 +182,10 @@ type ActivePanel =
type ActiveStarterScene = "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | null;
type ActivePresetControlMode =
+ | "character-face-three-view"
+ | "character-design-sheet"
+ | "scene-design-sheet"
+ | "product-design-sheet"
| "story-script"
| "character-three-view"
| "first-frame"
@@ -320,6 +379,7 @@ type DragState =
startY: number;
nodeX: number;
nodeY: number;
+ ids?: string[];
}
| {
kind: "canvas";
@@ -413,10 +473,11 @@ type CanvasModelOption = {
} | null;
};
-type CanvasModelKind = "image" | "video" | "multimodal" | "any";
-type AddModuleKind = "text" | "image" | "video" | "video-clip" | "director" | "audio" | "script" | "legacy-script";
+
+type AddModuleKind = "smart-edit" | "text" | "image" | "video" | "frame-analysis" | "video-clip" | "director" | "audio" | "script" | "legacy-script";
type CanvasCreateKind = AddModuleKind;
+type CanvasViewMode = "workflow" | "storyboard";
type TargetModuleActionLabel =
| "自己编写内容"
| "文生视频"
@@ -432,10 +493,20 @@ type TargetModuleActionLabel =
| "首帧生成视频"
| "自己编写分镜脚本"
| "生成高清视频"
+ | "打开智能剪辑"
+ | "讲解视频"
+ | "批量广告"
+ | "口播视频"
+ | "素材混剪"
| "打开导演台"
| "剧本生成分镜脚本"
| "视频参考生成分镜脚本"
- | "角色生成分镜脚本";
+ | "角色生成分镜脚本"
+ | "上传视频拉片"
+ | "从画布选择视频拉片"
+ | "拉片分镜"
+ | "拉片动态"
+ | "拉片音乐";
type ImageToolbarAction =
| "panorama"
@@ -451,16 +522,6 @@ type ImageToolbarAction =
| "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[];
@@ -618,6 +679,7 @@ type GridSplitState = {
};
type AngleEditorSettings = {
+ preset?: string;
horizontalAngle: number;
promptEnabled: boolean;
verticalAngle: number;
@@ -666,38 +728,6 @@ type StoryScriptRow = {
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;
@@ -740,10 +770,12 @@ const ADD_NODE_MODULES: Array<{
{ 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: "smart-edit", label: "智能剪辑", description: "自动选片、卡点、字幕与成片", icon: Scissors, badge: "Beta" },
{ kind: "director", label: "导演台", description: "3D空间多视角截图", icon: Settings2, badge: "NEW" },
+ { kind: "frame-analysis", label: "逐帧拉片", description: "上传视频,拆解分镜、动态与音乐", icon: ScanText, badge: "视频解析" },
{ kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 },
{ kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "NEW" },
+ { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" },
];
const ADD_NODE_PRIMARY_MODULES = ADD_NODE_MODULES.filter((item) => item.kind !== "script");
@@ -766,17 +798,20 @@ const CONNECTION_ADD_MODULES: Array<{
{ kind: "text", label: "文本生成", icon: FileText },
{ kind: "image", label: "图片生成", icon: ImageIcon },
{ kind: "video", label: "视频生成", icon: Video },
+ { kind: "smart-edit", label: "智能剪辑", icon: Scissors },
{ kind: "video-clip", label: "视频合成", icon: Scissors },
{ kind: "script", label: "脚本生成", icon: Layers },
];
const TARGET_MODULE_CENTER_OFFSETS: Record = {
+ "smart-edit": { x: 728, y: 670 },
audio: { x: 705, y: -257 },
image: { x: 53, y: -257 },
"legacy-script": { x: 556, y: 253 },
script: { x: 556, y: 253 },
text: { x: -742, y: -257 },
video: { x: -574, y: 253 },
+ "frame-analysis": { x: 420, y: 253 },
"video-clip": { x: 80, y: 253 },
director: { x: 728, y: 253 },
};
@@ -786,33 +821,41 @@ const STARTER_CARDS = [
title: "故事脚本生成",
subtitle: "脚本到分镜",
coverUrl:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/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",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
},
{
- title: "首帧图生视频",
+ title: "全能参考生视频",
subtitle: "参考图到成片",
coverUrl:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/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",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
icon: "music",
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
},
];
+const EMPTY_CANVAS_ACTIONS: Array<{ label: string; kind: AddModuleKind; icon: LucideIcon }> = [
+ { label: "图片生成", kind: "image", icon: ImageIcon },
+ { label: "视频生成", kind: "video", icon: Video },
+ { label: "音频生成", kind: "audio", icon: Music2 },
+ { label: "剧本生成", kind: "script", icon: FileText },
+ { label: "智能剪辑", kind: "smart-edit", icon: Scissors },
+];
+
const TOOLBOX_TEMPLATE_CATEGORIES = ["周星驰经典名场面", "电商展示", "镜头运动", "人物动画", "全部"] as const;
const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
@@ -822,7 +865,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "预设",
category: "周星驰经典名场面",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
duration: "5s",
id: "left-arc-slide",
@@ -837,7 +880,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用",
category: "电商展示",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
duration: "5s",
id: "phone-pop-product",
@@ -852,7 +895,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用",
category: "电商展示",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
duration: "5s",
id: "coffee-cup-entry",
@@ -867,7 +910,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门",
category: "镜头运动",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
duration: "6s",
id: "orbit-360-showcase",
@@ -881,7 +924,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "工业镜头",
category: "镜头运动",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
duration: "5s",
id: "robot-arm-pov",
@@ -896,7 +939,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "NEW",
category: "人物动画",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png",
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
duration: "4s",
id: "live-2d-character",
@@ -910,7 +953,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "电影运镜",
category: "镜头运动",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
duration: "5s",
id: "pupil-zoom",
@@ -925,7 +968,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门",
category: "人物动画",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
duration: "6s",
id: "bird-dissolve",
@@ -940,7 +983,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用",
category: "电商展示",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
duration: "5s",
id: "product-hero-entry",
@@ -954,7 +997,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "旅拍模板",
category: "镜头运动",
cover:
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
duration: "5s",
id: "travel-whip-transition",
@@ -997,176 +1040,6 @@ type VideoCanvasCustomMotionPreset = VideoCanvasMotionPreset & {
custom: true;
};
-const LIBLIB_VIDEO_MODEL_OPTIONS = [
- {
- label: "Seedance 2.0 VIP",
- duration: "2min",
- description: "最强视频模型,会员专属通道,15s音画同步",
- },
- {
- label: "Seedance 2.0 Fast VIP",
- duration: "2min",
- description: "最强视频模型快速版,会员专属通道,15s音画同步",
- },
- {
- label: "Seedance 2.0 Mini",
- duration: "2min",
- description: "最强视频模型mini版,高性价比生成,15s音画同步",
- },
- {
- label: "Happy Horse 1.1",
- duration: "3min",
- description: "阿里最新视频模型,一致性与视听质量更可控",
- },
- {
- label: "Happy Horse 1.0",
- duration: "3min",
- description: "阿里视频模型,支持多参生成",
- },
- {
- label: "Kling O3",
- duration: "3min",
- description: "视频编辑模型、参考一致性、音画同出、多镜头",
- },
- {
- label: "Kling 3.0 Turbo",
- duration: "3min",
- description: "视频生成模型,高质感、支持多镜头",
- },
- {
- label: "Kling 3.0",
- duration: "3min",
- description: "视频生成模型,高质感、支持多镜头",
- },
- {
- label: "Wan 2.7",
- duration: "3min",
- description: "全能参考,支持修改视频画面、剧情、环境",
- },
- {
- label: "Kling O1",
- duration: "3min",
- description: "可灵一代编辑模型、支持多模态输入",
- },
- {
- label: "Wan 2.6",
- duration: "3min",
- description: "音画同步,支持多机位镜头,最长可生15秒视频",
- },
- {
- label: "Hailuo 2.3",
- duration: "2min",
- description: "善于表达动作、表情、镜头,更高质感",
- },
- {
- label: "Seedance1.5 Pro",
- duration: "2min",
- description: "音画同步,支持多机位镜头,最长可生12秒视频",
- },
- {
- label: "Seedance 1.0 Pro",
- duration: "2min",
- description: "高精度提示词理解,40秒生成1080P视频",
- },
- {
- label: "Seedance 1.0 Lite",
- duration: "1min",
- description: "轻量快速,一键进行日常视频生成",
- },
- {
- label: "Kling 2.6",
- duration: "2min",
- description: "视频生成模型、直出音画同步",
- },
- {
- label: "Hailuo 02",
- duration: "2min",
- description: "画质稳定,适合打造运动特效场景",
- },
- {
- label: "Vidu Q2",
- duration: "3min",
- description: "多图主体参考,精确控制效果佳",
- },
- {
- label: "Vidu Q2 Pro",
- description: "Vidu Q2 Pro",
- },
- {
- label: "Vidu Q2 Turbo",
- description: "Vidu Q2 Turbo",
- },
- {
- label: "Vidu Q3 Pro",
- duration: "2min",
- description: "支持主体参考,精确控制效果佳",
- },
- {
- label: "OmniHuman 1.5",
- duration: "3min",
- description: "多模态数字人视频生成",
- },
- {
- label: "Kling 2.5",
- duration: "2min",
- description: "速度快、效果稳定、性价比高",
- },
- {
- label: "Kling 2.1",
- duration: "3min",
- description: "支持首尾帧,图生视频效果更出色",
- },
- {
- label: "Wan 2.2",
- duration: "3min",
- description: "支持特效、玩法千变万化",
- },
- {
- label: "Wan 2.5",
- duration: "3min",
- description: "支持特效、直出音画同步",
- },
- {
- label: "Pixverse V5.5",
- duration: "3min",
- description: "支持特效、玩法丰富",
- },
- {
- label: "Pixverse V5",
- duration: "3min",
- description: "支持特效、玩法丰富",
- },
- {
- label: "Hailuo 2.3 Fast",
- duration: "1min",
- description: "善于表达动作、表情、镜头,更快速",
- disabled: true,
- },
- {
- label: "Kling3.0 动作迁移",
- duration: "8min",
- description: "动作控制模型,需输入1张图片、1条视频",
- disabled: true,
- },
- {
- label: "MJ Video",
- duration: "2min",
- description: "图生视频效果稳定,画面表现力强",
- disabled: true,
- },
-] as const;
-
-const VIDEO_RATIO_OPTIONS = [
- { label: "Auto", value: "adaptive" },
- { label: "16:9", value: "16:9" },
- { label: "4:3", value: "4:3" },
- { label: "1:1", value: "1:1" },
- { label: "3:4", value: "3:4" },
- { label: "9:16", value: "9:16" },
- { label: "21:9", value: "21:9" },
-] as const;
-
-const VIDEO_RESOLUTION_OPTIONS = ["480P", "720P", "1080P", "4K"] as const;
const VIDEO_COUNT_OPTIONS = ["1个", "2个", "4个"] as const;
const VIDEO_AUDIO_OPTIONS = ["开启", "关闭"] as const;
@@ -1182,22 +1055,11 @@ const VIDEO_EFFECT_PRESETS = [
{ 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: "characterStudio", label: "角色造型室", icon: Smile },
{ id: "history", label: "历史记录", icon: Clock3 },
{ id: "shortcuts", label: "快捷键", icon: Keyboard },
{ id: "tutorial", label: "教程", icon: BookOpen },
@@ -1221,11 +1083,10 @@ const TOOLBOX_TEMPLATE_GROUP_FRAME = {
x: 6010,
y: -1440,
};
-const VIDEO_SUBTITLE_ERASE_SIMULATED_MS = 42_000;
const OFFICIAL_TEXT_RESOURCE_HEIGHT = 330;
const OFFICIAL_TEXT_RESOURCE_WIDTH = 560;
const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7;
-const OFFICIAL_COMPOSER_SCALE = 0.546;
+const OFFICIAL_COMPOSER_SCALE = 0.73;
const OFFICIAL_DROPDOWN_SCALE = 0.7;
const OFFICIAL_NINE_GRID_WIDTH = 930;
const OFFICIAL_NINE_GRID_HEIGHT = 520;
@@ -1242,7 +1103,7 @@ const LOCAL_EDIT_COLORS = [
"#8d6e63",
];
const VIDEO_CLIP_FRAME_COUNT = 9;
-const VIDEO_CROP_INLINE_LABEL = "1024 x 576";
+
const VIDEO_CROP_OUTPUT_LABEL = "1024 × 576";
const VIDEO_CROP_RECT = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
@@ -1299,19 +1160,9 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
];
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";
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/target-avatar.jpg";
const PUBLISH_MODAL_BG_URL =
- "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png";
+ "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/publish-modal-bg.png";
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
const AUDIO_TO_VIDEO_PROMPT =
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
@@ -1321,23 +1172,9 @@ 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 = [
@@ -1576,8 +1413,6 @@ function serializeNodes(nodes: CloneNode[]) {
},
},
connections: [],
- createdAt: new Date(),
- updatedAt: new Date(),
}));
}
@@ -1767,6 +1602,12 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index
};
}
+ if (kind === "smart-edit") {
+ return {id:makeId("smart-edit"),kind:"model",title:`智能剪辑 ${index}`,subtitle:"智能剪辑",inputType:"video",x,y,width:350,height:350,accent:"#ffb8c8",draftValues:{action:"SMART_EDIT_WORKFLOW",nodeType:"smart-edit",smartEditDraft:{mode:"auto",ratio:"16:9",resolution:"720p",targetDuration:30}}};
+ }
+ if (kind === "frame-analysis") {
+ return { id: makeId("frame-analysis"), kind: "model", title: "逐帧拉片", subtitle: "视频解析", x, y, width: 350, height: 350, accent: "#67a9bc", draftValues: { action: "FRAME_ANALYSIS", nodeType: "frame-analysis", dimensions: { shots: true, motion: true, music: true } } };
+ }
if (kind === "video-clip") {
return {
id: makeId("video-clip"),
@@ -1811,7 +1652,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index
};
}
- const meta: Record, {
+ const meta: Record, {
action: string;
accent: string;
kind: CloneNodeKind;
@@ -1983,7 +1824,7 @@ function createMaterialStyleNode({
kind: "material-style",
title: `素材-风格-${title}`,
subtitle: "风格素材",
- inputType: "image",
+ inputType: undefined,
x,
y,
width: MATERIAL_STYLE_NODE_SIZE,
@@ -2079,70 +1920,6 @@ function createMaterialRoleNode({
};
}
-function createHistoryDemoAssetNode({
- id,
- mediaType,
- mediaUrl,
- title,
-}: {
- id: string;
- mediaType: NonNullable;
- mediaUrl: string;
- title: string;
-}): CloneNode {
- const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片";
- return {
- id,
- kind: "media",
- title,
- subtitle: `${label}资源`,
- inputType: mediaType,
- x: 0,
- y: 0,
- width: mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420,
- height: mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280,
- accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
- draftValues: {
- action: `${mediaType.toUpperCase()}_RESOURCE`,
- alt: title,
- generated: true,
- historyDemo: true,
- mediaType,
- mediaUrl,
- nodeType: mediaType,
- url: [mediaUrl],
- },
- };
-}
-
-const DEFAULT_IMAGE_HISTORY_NODES: CloneNode[] = [
- ["history-demo-image-1", "角色图", CHARACTER_IMAGE_URL],
- ["history-demo-image-2", "角色三视图", CHARACTER_THREE_VIEW_URL],
- ["history-demo-image-3", "首帧", FIRST_FRAME_IMAGE_URL],
- ["history-demo-image-4", "音频参考图", AUDIO_REFERENCE_IMAGE_URL],
- ["history-demo-image-5", "高清图片", HD_IMAGE_SOURCE_URL],
- ["history-demo-image-6", "视频首帧", VIDEO_FIRST_FRAME_SOURCE_URL],
- ["history-demo-image-7", "首尾帧-首帧", VIDEO_FIRST_LAST_FIRST_URL],
- ["history-demo-image-8", "首尾帧-尾帧", VIDEO_FIRST_LAST_LAST_URL],
-].map(([id, title, mediaUrl]) => createHistoryDemoAssetNode({ id, mediaType: "image", mediaUrl, title }));
-
-const DEFAULT_VIDEO_HISTORY_NODES: CloneNode[] = Array.from({ length: 10 }, (_, index) =>
- createHistoryDemoAssetNode({
- id: `history-demo-video-${index + 1}`,
- mediaType: "video",
- mediaUrl: SCRIPT_VIDEO_REFERENCE_URL,
- title: `分镜视频-#${index + 1}`,
- })
-);
-
-const DEFAULT_AUDIO_HISTORY_NODES: CloneNode[] = Array.from({ length: 6 }, (_, index) =>
- createHistoryDemoAssetNode({
- id: `history-demo-audio-${index + 1}`,
- mediaType: "audio",
- mediaUrl: AUDIO_SOURCE_URL,
- title: `示例音频-#${index + 1}`,
- })
-);
const HISTORY_ASSET_ZOOM_LEVELS = [
{ cardSize: 130, label: "75%" },
@@ -2328,10 +2105,33 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node);
}
+function readyCanvasInputs(nodes: CloneNode[], edges: CloneEdge[], target: string) {
+ try {
+ const incoming = connectedInputNodes(nodes, edges, target);
+ const targetNode = nodes.find(node => node.id === target);
+ if (targetNode) for (const source of incoming) {
+ const error = connectionTypeError(source, targetNode);
+ if (error) throw new Error(error);
+ }
+ assertConnectedInputsReady(incoming);
+ return incoming;
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "上游输入不可用");
+ return null;
+ }
+}
+
function getNodeReferenceMediaUrl(node: CloneNode | null | undefined) {
return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl");
}
+function getNodePlayableVideoUrl(node: CloneNode | null | undefined) {
+ const direct = getDraftString(node, "mediaUrl");
+ if (direct) return direct;
+ const urls = node?.draftValues?.url;
+ return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : "";
+}
+
function normalizeVideoCanvasMagicMarks(value: unknown): VideoCanvasMagicMark[] {
if (!Array.isArray(value)) return [];
return value
@@ -2390,6 +2190,9 @@ function getVideoNodePreviewUrl(node: CloneNode | null | undefined) {
}
function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
+ const meta = node?.draftValues?.videoMeta as Record | undefined;
+ const recordedDuration = Number(meta?.durationSeconds || node?.draftValues?.durationSeconds || meta?.duration);
+ if (Number.isFinite(recordedDuration) && recordedDuration > 0) return recordedDuration;
const params = getNodeParams(node);
const paramsSettings =
params.settings && typeof params.settings === "object"
@@ -2413,6 +2216,13 @@ function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
return 5;
}
+function getVideoCropSelectionLabel(node: CloneNode) {
+ const rect = (node.draftValues?.cropRect || VIDEO_CROP_RECT) as GridCropRect;
+ const dimensions = getDraftString(node, "outputSizeLabel").match(/(\d+)\s*[×x]\s*(\d+)/);
+ if (!dimensions) return `裁剪区域 ${Math.round(rect.width * 100)}% × ${Math.round(rect.height * 100)}%`;
+ return `${Math.floor(Number(dimensions[1]) * rect.width / 2) * 2} × ${Math.floor(Number(dimensions[2]) * rect.height / 2) * 2}`;
+}
+
function formatClipDurationLabel(seconds: number) {
const normalized = Math.max(0.1, seconds);
return `${normalized.toFixed(2)} s`;
@@ -2446,118 +2256,6 @@ function normalizeVideoClipRange(value: unknown): VideoClipRange {
};
}
-function formatVideoStoryNumber(value: unknown, fallback: number) {
- const text = String(value ?? "").trim();
- if (text) return text.replace(/\s*秒$/i, "").replace(/\s*s$/i, "");
- const numericValue = Number.isFinite(fallback) ? fallback : 0;
- return Number.isInteger(numericValue)
- ? String(numericValue)
- : String(Number(numericValue.toFixed(1))).replace(/\.0$/, "");
-}
-
-function formatVideoStoryShot(value: unknown, index: number) {
- const text = String(value ?? "").trim();
- return text || String(index + 1);
-}
-
-function cleanVideoStoryText(value: unknown, fallback: string) {
- const text = String(value ?? "").trim();
- return text || fallback;
-}
-
-function buildVideoAnalysisRows(node: CloneNode, responseData?: VideoAnalysisResponsePayload): VideoAnalysisRow[] {
- const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || "";
- const modelRows = Array.isArray(responseData?.analysisRows) ? responseData.analysisRows : [];
- const framePreviews = Array.isArray(responseData?.framePreviews)
- ? responseData.framePreviews.map((item) => String(item || "").trim()).filter(Boolean)
- : [];
- const videoDuration = Math.max(1, Math.round(getVideoNodeDurationSeconds(node)));
- const defaultEndTime = formatVideoStoryNumber(undefined, videoDuration);
- const summary = responseData?.summary?.trim() || "";
- const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`;
- const createRow = (
- row: Partial & Record,
- index: number,
- ): VideoAnalysisRow => {
- const sceneDescription = cleanVideoStoryText(
- row.sceneDescription || row.description || row.pictureDescription,
- index === 0 ? `${node.title} 开场镜头` : `镜头 ${index + 1}`
- );
- const narrative = cleanVideoStoryText(
- row.narrative || row.story || row.storyContent || row.summary,
- sceneDescription
- );
- const motionPrompt = cleanVideoStoryText(
- row.motionPrompt || row.videoPrompt,
- "The camera slowly pushes toward the main subject while the action continues naturally and smoothly."
- );
-
- return {
- id: `${node.id}-analysis-${index}`,
- shot: formatVideoStoryShot(row.shot || row.shotNumber || row.index, index),
- startTime: formatVideoStoryNumber(row.startTime, index === 0 ? 0 : index * videoDuration),
- endTime: formatVideoStoryNumber(row.endTime, (index + 1) * videoDuration),
- duration: formatVideoStoryNumber(row.duration, videoDuration),
- sceneDescription,
- narrative,
- shotSize: cleanVideoStoryText(row.shotSize || row.frameSize, "中景至近景"),
- cameraAngle: cleanVideoStoryText(row.cameraAngle || row.angle, "平视"),
- cameraMovement: cleanVideoStoryText(row.cameraMovement || row.cameraMove, "缓慢推镜头 (Slow Zoom In)"),
- focusDepth: cleanVideoStoryText(row.focusDepth || row.depthOfField, "浅景深"),
- lighting: cleanVideoStoryText(row.lighting || row.light, "冷调低沉照明,主体光线柔和。"),
- backgroundMusic: cleanVideoStoryText(row.backgroundMusic || row.music, "无"),
- voiceAndSfx: cleanVideoStoryText(row.voiceAndSfx || row.audio || row.sfx, "无"),
- imagePrompt: cleanVideoStoryText(row.imagePrompt || row.sceneDescription, fallbackPrompt),
- motionPrompt,
- keyframeUrl:
- String(row.keyframeUrl || row.previewUrl || row.imageUrl || "").trim() ||
- framePreviews[index % Math.max(framePreviews.length, 1)] ||
- previewUrl,
- };
- };
-
- if (modelRows.length > 0) {
- return modelRows.slice(0, 8).map((row, index) => createRow(row, index));
- }
- const highlights = responseData?.highlights || [];
- const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || [];
- const rows =
- shotIdeas.length > 0
- ? shotIdeas.map((idea, index) =>
- createRow(
- {
- backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入",
- imagePrompt: idea.description || fallbackPrompt,
- motionPrompt:
- responseData?.recommendedNextSteps?.[index] ||
- "The camera moves smoothly around the subject and keeps the action continuous.",
- narrative: idea.description || fallbackPrompt,
- sceneDescription: idea.title || `镜头 ${index + 1}`,
- voiceAndSfx: highlights[index + 1] || "无",
- },
- index
- )
- )
- : [
- createRow(
- {
- backgroundMusic: highlights[0] || "低沉的氛围电子乐,带有缓慢的机械节拍感。",
- endTime: defaultEndTime,
- imagePrompt: fallbackPrompt,
- motionPrompt:
- "The subject moves subtly while the camera slowly dollies in, keeping the scene cinematic and continuous.",
- narrative: summary || "通过主要角色、环境和镜头运动呈现当前视频的叙事情绪。",
- sceneDescription: `${node.title} 开场镜头`,
- startTime: "0",
- voiceAndSfx: highlights[1] || "环境声与轻微动作声。",
- },
- 0
- ),
- ];
-
- return rows;
-}
-
function createVideoStoryAnalysisNode({
existingNode,
framePreviews = [],
@@ -2573,7 +2271,7 @@ function createVideoStoryAnalysisNode({
rows: VideoAnalysisRow[];
summary: string;
}): CloneNode {
- const previewUrl = getVideoNodePreviewUrl(sourceNode) || getNodeMediaUrl(sourceNode) || "";
+ const previewUrl = getVideoNodePreviewUrl(sourceNode) || "";
return {
id: existingNode?.id || makeId("video-story"),
kind: "video-story",
@@ -2636,7 +2334,7 @@ function createVideoHdUpscaleNode({
action: "VIDEO_HD_UPSCALE",
frameInterpolation: option === "不补帧" ? "不补帧" : existingNode?.draftValues?.frameInterpolation || "不补帧",
generated: existingNode?.draftValues?.generated || false,
- hdEngine: option === "Topazlabs" ? "Topazlabs" : existingNode?.draftValues?.hdEngine || "Topazlabs",
+ hdEngine: option === "Real-ESRGAN" ? "Real-ESRGAN" : existingNode?.draftValues?.hdEngine || "Real-ESRGAN",
hdResolution: resolution,
mediaType: "video",
nodeType: "video-hd",
@@ -2657,7 +2355,7 @@ function createVideoCropResultNode({
existingIndex: number;
sourceNode: CloneNode;
}): CloneNode {
- const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL;
+ const sourceUrl = getNodeReferenceMediaUrl(sourceNode);
const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl;
const width = sourceNode.width || 560;
const height = sourceNode.height || 315;
@@ -2704,7 +2402,7 @@ function createVideoSubtitleEraseNode({
regions?: SubtitleEraseRegion[];
sourceNode: CloneNode;
}): CloneNode {
- const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL;
+ const sourceUrl = getNodeReferenceMediaUrl(sourceNode);
const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl;
const outputSizeLabel = getDraftString(sourceNode, "outputSizeLabel") || "1280 × 720";
const normalizedRegions = regions.map(({ height, width, x, y }) => ({
@@ -2788,6 +2486,9 @@ function normalizeStoryScriptRow(row: unknown, index: number, nodeId: string): S
if (!row || typeof row !== "object") return null;
const record = row as Record;
+ if (["shot","duration","scene","sceneDescription","shotSize","lighting","dialogue","backgroundMusic","voiceAndSfx","cameraMovement","imagePrompt","motionPrompt","keyframeUrl"].every(key => typeof record[key] === "string")) {
+ return { ...record, id: typeof record.id === "string" ? record.id : `${nodeId}-story-row-${index}` } as StoryScriptRow;
+ }
const shot = formatStoryShot(record.shot || record.id, index);
const duration = String(record.duration || record.length || record.time || "5s").trim();
const scene = String(record.scene || record.title || record.sceneDescription || `镜头 ${shot}`).trim();
@@ -2847,134 +2548,25 @@ function getStoryScriptRows(node: CloneNode | null | undefined): StoryScriptRow[
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}`,
- }));
+ return rows;
}
type StoryWorkflowAsset = {
+ id?: string;
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"),
- };
+ const names = [...new Set(rows.flatMap(row =>
+ [row.scene, row.sceneDescription, row.imagePrompt, row.motionPrompt].join(" ").match(/@[\p{L}\p{N}_-]+/gu) || []))];
+ const assets: StoryWorkflowAsset[] = names.map(name => ({
+ name: name.slice(1), description: "脚本引用,请确认类型并准备图片", imageUrl: "",
+ type: /场景|街|城|室|厅|山|海/.test(name) ? "scene" : /刀|剑|杯|道具/.test(name) ? "prop" : "role",
+ }));
+ return { role: assets.filter(a => a.type === "role"), scene: assets.filter(a => a.type === "scene"), prop: assets.filter(a => a.type === "prop") };
}
function buildStorySceneText(row: StoryScriptRow) {
@@ -3000,7 +2592,7 @@ function buildStoryVideoPrompt(row: StoryScriptRow) {
return [
row.imagePrompt,
motionPrompt,
- "[视觉风格:盛唐·国风二次元]",
+
].filter(Boolean).join("\n\n");
}
@@ -3030,392 +2622,434 @@ function StoryScriptWorkflowDialog({
node,
onClose,
onRun,
+ onPatch,
onStepChange,
step,
+ imageNodes,
+ imageModel,
+ analysisModel,
+ taskBusy = false,
+ taskError,
+ onSubmitTask,
}: {
node: CloneNode;
onClose: () => void;
onRun: () => void;
+ onPatch: (patch: Record) => void;
onStepChange: (step: StoryWorkflowStep) => void;
step: StoryWorkflowStep;
+ imageNodes: CloneNode[];
+ imageModel?: string;
+ analysisModel?: string;
+ taskBusy?: boolean;
+ taskError?: string;
+ onSubmitTask?: (
+ kind: "asset" | "prompts",
+ data: {
+ assets: StoryWorkflowAsset[];
+ rows: StoryScriptRow[];
+ assetIndex?: number;
+ style: string;
+ },
+ ) => Promise;
}) {
const rows = getStoryScriptRows(node);
- const 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 detected = getStoryWorkflowAssetGroups(rows);
+ const assets = (
+ Array.isArray(node.draftValues?.storyAssets)
+ ? node.draftValues.storyAssets
+ : [...detected.role, ...detected.scene, ...detected.prop]
+ ) as StoryWorkflowAsset[];
+ const style = String(node.draftValues?.storyStyle || "");
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState("");
+ const uploadInput = useRef(null);
+ const uploadIndex = useRef(0);
+ const latest = useRef({ rows, assets });
+ latest.current = { rows, assets };
+ const updateAsset = (index: number, patch: Partial) =>
+ onPatch({
+ storyAssets: latest.current.assets.map((asset, i) =>
+ i === index ? { ...asset, ...patch } : asset,
+ ),
+ });
+ const updateRow = (index: number, patch: Partial) =>
+ onPatch({
+ rows: latest.current.rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
+ });
+ const compose = async (index?: number) => {
+ if (busy || taskBusy) return;
+ const source = index === undefined ? rows : [rows[index]];
+ if (!source.length) return;
+ setBusy("prompts");
+ setError("");
+ try {
+ if (!onSubmitTask) throw new Error("任务服务尚未就绪");
+ await onSubmitTask("prompts", { assets, rows: source, style });
+ } catch (error) {
+ setError(error instanceof Error ? error.message : "提示词提交失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+ const generateAsset = async (index: number) => {
+ if (busy || taskBusy) return;
+ setBusy("asset");
+ setError("");
+ try {
+ if (!onSubmitTask) throw new Error("任务服务尚未就绪");
+ await onSubmitTask("asset", { assets, rows, style, assetIndex: index });
+ } catch (error) {
+ setError(error instanceof Error ? error.message : "资产提交失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+ const field =
+ "w-full rounded border border-white/10 bg-white/5 p-2 text-xs outline-none focus:border-white/40";
+ const button =
+ "rounded-lg border border-white/10 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40";
+ const fields: Array<[keyof StoryScriptRow, string]> = [
+ ["shot", "镜号"],
+ ["duration", "时长"],
+ ["sceneDescription", "画面描述"],
+ ["shotSize", "景别"],
+ ["lighting", "光影"],
+ ["dialogue", "对白"],
+ ["voiceAndSfx", "音效"],
+ ["cameraMovement", "运镜"],
+ ["imagePrompt", "图像提示词"],
+ ["motionPrompt", "运动提示词"],
];
- const 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) => (
-
- ))}
-
- setPromptRow(row)}
- type="button"
- >
- 查看提示词
-
-
- ⋯
-
-
-
- ))}
-
-
-
- );
-
- const renderAssetGroup = (
- title: string,
- assets: StoryWorkflowAsset[],
- emptyLabel: string,
- ) => (
-
- {title}
-
- {assets.map((asset) => (
-
-

-
- {asset.name}
- {asset.description}
-
-
-
- ⌕
-
-
-
- ⋯
-
-
- {["替换图片", "AI 生角色", "清除图片"].map((item) => (
-
{item}
- ))}
-
删除
-
-
-
-
- ))}
-
-
- {emptyLabel}
-
-
-
- );
-
+ const readyAssets = assets.filter((asset) => asset.imageUrl).length;
+ const readyPrompts = rows.filter(
+ (row) => row.imagePrompt.trim() && row.motionPrompt.trim(),
+ ).length;
return (
event.stopPropagation()}
>
-
-
- {node.title || "脚本生成器"}
-
-
- {steps.map((item, index) => {
- const active = item.key === step;
- return (
- onStepChange(item.key)}
- type="button"
- >
-
- {index + 1}
-
-
-
- {item.label}
-
-
- {item.subLabel}
-
-
-
- );
- })}
-
-
- 3/3 完成后可批量生视频
-
-
-
+
+ {node.title || "脚本工作流"}
+
+
+
-
-
- {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} 镜:最终提示词
- setPromptRow(null)}
- type="button"
- >
-
-
-
-
-
-
-
分镜提示词
-
- 分镜图提示词·已生成
-
-
- {promptRow.imagePrompt.includes("[视觉风格") ? promptRow.imagePrompt : `${promptRow.imagePrompt} [视觉风格:盛唐·国风二次元]`}
-
-
-
-
视频运动提示词
-
- 视频运动提示词·已生成
-
-
-
- {promptRow.motionPrompt.includes("起始状态")
- ? promptRow.motionPrompt
- : `起始状态:${promptRow.sceneDescription} 动作过程:${promptRow.cameraMovement},角色动作自然连贯。 结束状态:镜头停留在关键情绪或剧情转折点。 音效:${promptRow.voiceAndSfx} [视觉风格:盛唐·国风二次元]`}
-
-
-
-
-
GVLM 3.1 Flash
-
- 消耗 2
- toast.success("已重新合成当前镜头提示词")}
- type="button"
- >
- 重新合成提示词
-
-
-
-
-
+ {error && (
+
+ {error}
+
+ )}
+ {taskError ? (
+
+ {taskError}
+
) : null}
+ {taskBusy ? (
+
+ 生成任务处理中,可关闭窗口,结果会自动保存并同步。
+
+ ) : null}
+ {Object.values(
+ (node.draftValues?.storyTaskResults || {}) as Record<
+ string,
+ { status: string; message: string }
+ >,
+ )
+ .slice(-1)
+ .map((result, index) => (
+
+ {result.message}
+
+ ))}
+
+
+ {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+ setBusy("upload");
+ setError("");
+ try {
+ const result = await imageGenerationService.uploadImage(file);
+ if (!result.success || !result.data?.url) throw new Error(result.error || "上传失败");
+ updateAsset(uploadIndex.current, { imageUrl: result.data.url });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "上传失败");
+ } finally {
+ setBusy(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));
+ return normalizeVideoAnalysisRows(rawRows, [], node?.id || "video-story");
}
function getGridSplitCellLabel(index: number, split: Pick) {
@@ -3427,6 +3061,13 @@ function getGridSplitCellLabel(index: number, split: Pick = {
+ "角色脸部三视图": "character-face-three-view",
+ "角色设定图": "character-design-sheet",
+ "场景设定图": "scene-design-sheet",
+ "产品设定图": "product-design-sheet",
+ };
+ if (designPresets[text]) return designPresets[text];
if (/角色三视图|三视图生成|正侧背|character[-_\s]?(three|turnaround)/i.test(text)) {
return CHARACTER_THREE_VIEW_FUNCTION_KEY;
}
@@ -3499,6 +3140,10 @@ function isWideImageGenerationFunction(functionKey: string) {
function isOfficialImagePresetFunctionKey(functionKey: string) {
return (
+ functionKey === "character-face-three-view" ||
+ functionKey === "character-design-sheet" ||
+ functionKey === "scene-design-sheet" ||
+ functionKey === "product-design-sheet" ||
functionKey === STORY_BEAT_GRID_FUNCTION_KEY ||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ||
functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY ||
@@ -3555,12 +3200,13 @@ 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";
+ if (definition.kind === "multimodal" || definition.route === "canvas-workflow-analyze" || definition.route === "canvas-prompt-tools") return "text";
return "image";
}
function functionRequiresMedia(definition: VideoCanvasFunctionDefinition) {
if (definition.route === "local") return false;
+ if (definition.kind === "image") return false;
if (definition.key === "text-to-video" || definition.key === "story-script") return false;
return (
definition.kind === "image-edit" ||
@@ -3631,7 +3277,7 @@ function getToolReferenceModuleTemplates(
function getToolReferenceMediaSpec(
template: { kind: AddModuleKind; title?: string },
definition: VideoCanvasFunctionDefinition,
- index: number,
+ _index: number,
): {
action: string;
height: number;
@@ -3646,7 +3292,7 @@ function getToolReferenceMediaSpec(
action: "AUDIO_RESOURCE",
height: 190,
mediaType: "audio",
- mediaUrl: AUDIO_SOURCE_URL,
+ mediaUrl: "",
title,
width: 720,
};
@@ -3656,26 +3302,17 @@ function getToolReferenceMediaSpec(
action: "VIDEO_RESOURCE",
height: 315,
mediaType: "video",
- mediaUrl: SCRIPT_VIDEO_REFERENCE_URL,
+ mediaUrl: "",
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,
+ mediaUrl: "",
title,
width: definition.key === "audio-to-video" ? 720 : 420,
};
@@ -3696,7 +3333,7 @@ function getFunctionModel(
const fallback =
definition.kind === "video"
? config.defaultVideoModel
- : definition.kind === "multimodal"
+ : definition.kind === "multimodal" || definition.route === "canvas-prompt-tools"
? config.defaultMultimodalModel
: config.defaultImageModel;
return normalizeModelRef(definition.model || fallback) || undefined;
@@ -3709,7 +3346,7 @@ function getFunctionModelValue(
const fallback =
definition.kind === "video"
? config.defaultVideoModel
- : definition.kind === "multimodal"
+ : definition.kind === "multimodal" || definition.route === "canvas-prompt-tools"
? config.defaultMultimodalModel
: config.defaultImageModel;
return String(definition.model || fallback || "auto").trim() || "auto";
@@ -3760,6 +3397,13 @@ function normalizeVideoResolutionValue(value: unknown): VideoResolution {
return "720p";
}
+function assertExecutableVideoResolution(value: unknown) {
+ const requested = String(value || "").trim().toLowerCase();
+ if (["4k", "2160p", "2k", "1440p"].includes(requested)) {
+ throw new Error("当前视频生成最高支持 1080P;更高画质请在生成后使用视频高清");
+ }
+}
+
function formatVideoResolutionLabel(value: unknown) {
const resolution = normalizeVideoResolutionValue(value);
return resolution === "480p" ? "480P" : resolution === "1080p" ? "1080P" : "720P";
@@ -3804,10 +3448,12 @@ function getVideoFunctionKeyForMode(mode: VideoGenerationMode) {
}
function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode) {
+ const error = videoInputLimitError(mode, images.length);
+ if (error) throw new Error(error);
if (mode === "text-to-video") return [];
if (mode === "first-frame") return images.slice(0, 1);
if (mode === "first-last-frame") return images.slice(0, 2);
- return images.slice(0, 4);
+ return images;
}
function getCanvasModelRef(model: CanvasModelOption) {
@@ -3833,18 +3479,7 @@ function isCanvasVideoModel(model: CanvasModelOption) {
}
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;
+ return filterCanvasModels(models, kind);
}
function getCanvasModelDisplayName(
@@ -3919,6 +3554,8 @@ function resolveVideoCanvasBillingDisplay(params: {
};
}
+ if (!params.membershipSummary) return fallback;
+
if (pointsConfig.enabled !== true) {
return {
label: "免费",
@@ -3963,7 +3600,7 @@ function resolveVideoCanvasBillingDisplay(params: {
return fallback;
}
- if (params.modelKind === "image") {
+ if (params.modelKind === "image" || params.modelKind === "image-edit") {
const unitCost = getImageCreditCostFromConfig(matchedModel.imageGenConfig);
if (unitCost > 0) {
const safeImageCount = Math.max(
@@ -4401,14 +4038,7 @@ async function resolveNodeReferenceImages(node: CloneNode | null | undefined, li
})
);
- const urls = Array.from(
- new Set(
- resolved
- .map((item) => String(item || "").trim())
- .filter(Boolean)
- )
- );
- return urls.length > 0 ? urls : fallbackUrl ? [fallbackUrl] : [];
+ return requireResolvedReferenceImages(resolved, selectedIndices.length);
}
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
@@ -4436,10 +4066,11 @@ function getPresetControlMode(node: CloneNode | null | undefined): ActivePresetC
}
function isOfficialImageControlNode(node: CloneNode | null | undefined): node is CloneNode {
- return !!node && (node.kind === "media" || node.kind === "script") && !!getPresetControlMode(node);
+ return !!node && !(getPresetMode(node) === "first-frame" && node.inputType === "video") && (node.kind === "media" || node.kind === "script") && !!getPresetControlMode(node);
}
function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
+ if (getPresetMode(node) === "first-frame" && node.inputType === "video") return "video";
if (node.kind === "media" || getPresetMode(node)) return null;
if (getDraftString(node, "action") === "TEXT_RESOURCE" || getDraftString(node, "scriptRole") === "resource") {
return null;
@@ -4449,7 +4080,9 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
nodeType === "text" ||
nodeType === "image" ||
nodeType === "video" ||
+ nodeType === "smart-edit" ||
nodeType === "video-clip" ||
+ nodeType === "frame-analysis" ||
nodeType === "director" ||
nodeType === "audio" ||
nodeType === "script-v2" ||
@@ -4467,7 +4100,9 @@ function extractTrailingNumber(value: string | undefined) {
}
function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null {
- if (node.kind === "media" || getPresetMode(node)) return null;
+ // Explicit upload/input placeholders are resources, not legacy generator nodes.
+ // Reclassifying them removes the file picker after the first save + reload.
+ if (node.kind === "media" || node.kind === "input" || getPresetMode(node)) return null;
const action = getDraftString(node, "action");
if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null;
@@ -4521,8 +4156,9 @@ function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModule
const value = String(title || "").trim();
if (!value) return true;
const legacyTitlePattern =
- /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点|导演台)(\s*\d+)?$/;
+ /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点|逐帧拉片|导演台)(\s*\d+)?$/;
if (!legacyTitlePattern.test(value)) return false;
+ if (kind === "frame-analysis") return value !== "逐帧拉片";
if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value);
if (kind === "director") return !/^导演台\s*\d+$/.test(value);
if (kind === "script") return value !== "脚本生成器";
@@ -4563,7 +4199,19 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac
}
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
+ if (getDraftString(node, "uploadStatus") === "uploading" ||
+ (node.kind === "media" && getNodeMediaUrl(node).startsWith("blob:"))) {
+ return { ...node, draftValues: { ...node.draftValues, mediaUrl: "", url: [],
+ uploadStatus: "failed", uploadError: "上次素材未完成上传,请重新上传文件" } };
+ }
const action = getDraftString(node, "action");
+ if (action === "VIDEO_RESOURCE" && node.width === 420 && node.height === 280) {
+ const sizeLabel = getDraftString(node, "outputSizeLabel").match(/(\d+)\s*[×x]\s*(\d+)/);
+ const width = getDraftNumber(node, "intrinsicWidth", Number(sizeLabel?.[1] || 0));
+ const height = getDraftNumber(node, "intrinsicHeight", Number(sizeLabel?.[2] || 0));
+ const layout = getUploadedMediaNodeSize(width, height, "video");
+ if (layout) return { ...node, ...layout };
+ }
const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node);
const isNineGridResultNode =
!isCharacterThreeViewResultNode &&
@@ -4662,11 +4310,14 @@ function createPresetMediaNode({
x: number;
y: number;
}): CloneNode {
+ const isGenerator = /GENERATE/.test(action);
+ if (isGenerator) { generated = false; mediaUrl = ""; }
+ const isExample = !isGenerator && Boolean(mediaUrl);
return {
id: makeId(mediaType),
kind: "media",
- title,
- subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源",
+ title: isExample ? `${title}(示例)` : title,
+ subtitle: isGenerator ? "待生成" : isExample ? "模板示例素材,可替换" : mediaType === "audio" ? "音频资源" : "素材资源",
inputType: mediaType,
x,
y,
@@ -4676,6 +4327,8 @@ function createPresetMediaNode({
draftValues: {
action,
generated,
+ isExample,
+ generationStatus: isGenerator ? "idle" : undefined,
mediaType,
mediaUrl: mediaUrl || "",
params: prompt ? { prompt } : undefined,
@@ -4713,7 +4366,7 @@ function createPresetScriptNode({
accent: "#f0d06b",
draftValues: {
action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE",
- content: isGenerator ? [] : [STORY_SCRIPT_TEXT],
+ content: [],
params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined,
prompt: isGenerator ? STORY_SCRIPT_PROMPT : "",
rows: isGenerator ? [] : undefined,
@@ -4763,19 +4416,16 @@ function createStarterWorkflow(
action: "IMAGE_RESOURCE",
height: 520,
mediaType: "image",
- mediaUrl: CHARACTER_IMAGE_URL,
preset,
- title: "角色图",
+ 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: "角色三视图生成",
@@ -4799,15 +4449,14 @@ function createStarterWorkflow(
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
}
- if (starterTitle === "首帧图生视频") {
+ if (starterTitle === "首帧图生视频" || starterTitle === "全能参考生视频") {
const preset = "first-frame";
const left = createPresetMediaNode({
action: "IMAGE_RESOURCE",
height: 430,
mediaType: "image",
- mediaUrl: FIRST_FRAME_IMAGE_URL,
preset,
- title: "首帧",
+ title: "上传首帧",
width: 620,
x: Math.round(center.x - 675),
y: Math.round(center.y - 310),
@@ -4834,9 +4483,8 @@ function createStarterWorkflow(
action: "AUDIO_RESOURCE",
height: 190,
mediaType: "audio",
- mediaUrl: AUDIO_SOURCE_URL,
preset,
- title: "音频",
+ title: "上传音频",
width: 720,
x: leftX,
y: leftY,
@@ -4845,9 +4493,8 @@ function createStarterWorkflow(
action: "IMAGE_RESOURCE",
height: 400,
mediaType: "image",
- mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
preset,
- title: "图片",
+ title: "上传画面参考",
width: 720,
x: leftX,
y: leftY + 300,
@@ -4877,10 +4524,20 @@ export function LiblibVideoCanvasReplica({
}: LiblibVideoCanvasReplicaProps) {
const router = useRouter();
const canvasRef = useRef(null);
+ const uploadQueueRef = useRef>(Promise.resolve());
+ const fileDragDepth = useRef(0);
+ const [fileDragActive, setFileDragActive] = useState(false);
+ const replaceFileInputRef=useRef(null);
+ const replaceFileNodeId=useRef(null);
+ const pendingFrameAnalysisUploadRef=useRef(null);
+ const homeStarterAppliedRef = useRef(null);
+ const homeStarterSavingRef = useRef(false);
const connectionDragCleanupRef = useRef<(() => void) | null>(null);
const connectionDragRef = useRef(null);
const assetManagerResizeCleanupRef = useRef<(() => void) | null>(null);
const dragRef = useRef(null);
+ const [panMode, setPanMode] = useState(false);
+ const spacePanRef = useRef(false);
const suppressConnectionClickRef = useRef(false);
const { data: siteBrandSettings } = useSWR(
"/api/site-settings?scope=branding",
@@ -4915,16 +4572,46 @@ export function LiblibVideoCanvasReplica({
[siteBrandSettings?.siteName]
);
const [nodes, setNodes] = useState([]);
+ const activeGenerationSubmissions = useRef(new Set());
+ const [isVideoAgentOpen, setIsVideoAgentOpen] = useState(false);
+ const receivedAgentNodeIds = useRef(new Set());
const [edges, setEdges] = useState([]);
const [selectedNodeId, setSelectedNodeId] = useState(null);
+ useEffect(() => {
+ setNodes((current) => {
+ let changed = false;
+ const next = current.map((node) => {
+ if (getTargetModuleKind(node) !== "image") return node;
+ const selected = node.id === selectedNodeId;
+ const width = selected ? 622 : 350;
+ const height = selected ? 390 : 350;
+ if (node.width === width && node.height === height) return node;
+ changed = true;
+ return { ...node, width, height };
+ });
+ return changed ? next : current;
+ });
+ }, [nodes, selectedNodeId]);
+ const [multiSelection, setMultiSelection] = useState([]);
+ const [groupTemplates,setGroupTemplates]=useState([]);
+ const {data:savedGroupTemplates}=useSWR<{templates:any[]}>("/api/canvas/group-templates",fetcher);
+ const [groupSubmitting,setGroupSubmitting]=useState(false);
+ const [storyboardGroupId,setStoryboardGroupId]=useState(null);
+ const [selectionRect,setSelectionRect]=useState<{x:number;y:number;width:number;height:number}|null>(null);
+ const marqueeStart=useRef<{x:number;y:number}|null>(null);
+ const groupResize=useRef<{id:string;x:number;y:number;width:number;height:number;startX:number;startY:number;corner:string}|null>(null);
+
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [lineConnectState, setLineConnectState] = useState(null);
const [referenceSelectionTargetId, setReferenceSelectionTargetId] = useState(null);
+ const [frameAnalysisSelectionTargetId, setFrameAnalysisSelectionTargetId] = useState(null);
const [markSelectionTargetId, setMarkSelectionTargetId] = useState(null);
const [markAnalysisState, setMarkAnalysisState] =
useState