diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts
index c1ada40aa..131016b6e 100644
--- a/app/(chat)/api/models/route.ts
+++ b/app/(chat)/api/models/route.ts
@@ -102,8 +102,8 @@ export async function GET(request: Request) {
},
{
id: "local-e2e-video-model",
- modelId: "kling-3-local",
- displayName: "可灵 3.0",
+ modelId: "veo-3.1-fast",
+ displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
@@ -111,18 +111,18 @@ export async function GET(request: Request) {
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
- providerType: "local",
+ providerType: "vip98k",
isSubModel: false,
parentModelId: null,
- capabilities: { video: true },
+ capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
videoToolCapability: {
supported: true,
- durations: [5, 10],
+ durations: [8],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
- videoDurationOptions: [5, 10],
+ videoDurationOptions: [8],
},
sortOrder: 1,
selectable: true,
diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts
index 4d605a07a..f31a24191 100644
--- a/app/api/canvas/generate/image/route.ts
+++ b/app/api/canvas/generate/image/route.ts
@@ -31,6 +31,7 @@ function normalizeBillingFeature(
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
+ allowFailover: typeof body.allowFailover === "boolean" ? body.allowFailover : undefined,
prompt: String(body.prompt || "").trim(),
negativePrompt:
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
diff --git a/app/api/canvas/generate/video/route.ts b/app/api/canvas/generate/video/route.ts
index aac3f4a9f..f36270cfb 100644
--- a/app/api/canvas/generate/video/route.ts
+++ b/app/api/canvas/generate/video/route.ts
@@ -10,6 +10,8 @@ interface GenerateVideoRequest {
prompt?: string;
mode?: VideoGenerationMode;
images?: string[];
+ audioUrls?: string[];
+ generateAudio?: boolean;
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number;
@@ -47,12 +49,12 @@ export async function POST(request: Request) {
}
const inputImages = Array.isArray(body.images)
- ? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
+ ? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8)
: [];
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
- if (inputImages.length > 0 && reachableImages.length === 0) {
+ if (unreachableImages.length > 0) {
return NextResponse.json(
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
{ status: 400 }
@@ -63,6 +65,8 @@ export async function POST(request: Request) {
const result = await executeDirectVideoGeneration({
prompt,
mode,
+ audioUrls: Array.isArray(body.audioUrls) ? body.audioUrls.filter(url => typeof url === "string" && /^https?:\/\//i.test(url)).slice(0, 3) : undefined,
+ generateAudio: body.generateAudio,
images: reachableImages,
resolution: body.resolution,
ratio: body.ratio,
diff --git a/app/api/canvas/video/process/route.ts b/app/api/canvas/video/process/route.ts
new file mode 100644
index 000000000..923d94e60
--- /dev/null
+++ b/app/api/canvas/video/process/route.ts
@@ -0,0 +1,39 @@
+import {auth} from '@/app/(auth)/auth';
+import {NextResponse} from 'next/server';
+import {z} from 'zod';
+import {mkdtemp,readFile,rm} from 'node:fs/promises';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import {randomUUID} from 'node:crypto';
+import {uploadFile} from '@/lib/storage/service';
+import {downloadCanvasMedia} from '@/lib/video-canvas/download-media';
+import {processVideoFile} from '@/lib/video-canvas/media-processing';
+export const runtime='nodejs';
+export const maxDuration=300;
+const running=new Set
();
+const schema=z.object({operation:z.enum(['crop','audio-video']),url:z.string().url(),rect:z.object({x:z.number(),y:z.number(),width:z.number(),height:z.number()}).optional()});
+export async function POST(request:Request) {
+ const session=await auth();
+ if(!session?.user?.id) return NextResponse.json({success:false,error:'未登录'},{status:401});
+ const parsed=schema.safeParse(await request.json().catch(()=>null));
+ if(!parsed.success) return NextResponse.json({success:false,error:'视频处理参数无效'},{status:400});
+ if(running.has(session.user.id)||running.size>=2) return NextResponse.json({success:false,error:'视频处理中,请稍后再提交'},{status:429});
+ running.add(session.user.id);
+ let dir='';
+ try {
+ dir=await mkdtemp(join(tmpdir(),'canvas-video-'));
+ const input=join(dir,'input');
+ await downloadCanvasMedia(parsed.data.url,input);
+ const save=async(name:string)=>{const buffer=await readFile(join(dir,name));return (await uploadFile(`canvas-video-${randomUUID()}-${name}`,new Uint8Array(buffer).buffer)).url;};
+ if(parsed.data.operation==='crop') {
+ const metadata=await processVideoFile(input,join(dir,'crop.mp4'),'crop',parsed.data.rect);
+ return NextResponse.json({success:true,data:{url:await save('crop.mp4'),...metadata}});
+ }
+ const metadata=await processVideoFile(input,join(dir,'audio.m4a'),'extract-audio');
+ await processVideoFile(input,join(dir,'silent.mp4'),'silent-video');
+ return NextResponse.json({success:true,data:{audioUrl:await save('audio.m4a'),url:await save('silent.mp4'),...metadata}});
+ } catch(error) {
+ const message=error instanceof Error ? error.message : '视频处理失败';
+ return NextResponse.json({success:false,error:message.startsWith('Command failed')?'视频处理失败,请确认文件完整且格式受支持':message},{status:400});
+ } finally {running.delete(session.user.id);if(dir)await rm(dir,{recursive:true,force:true});}
+}
diff --git a/app/api/canvas/workflow/analyze/route.ts b/app/api/canvas/workflow/analyze/route.ts
index a2c4b744e..08c5633db 100644
--- a/app/api/canvas/workflow/analyze/route.ts
+++ b/app/api/canvas/workflow/analyze/route.ts
@@ -1,3 +1,4 @@
+import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
import { generateText } from "ai";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -776,6 +777,11 @@ function buildStoryScriptPrompt(params: {
export async function POST(request: Request) {
try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
const body = await request.json().catch(() => ({}));
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
@@ -800,50 +806,27 @@ export async function POST(request: Request) {
);
}
- const session = await auth();
- if (!session?.user?.id) {
- return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
- }
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
- const compatibleModels = allModels.filter(
- (item) => !item.model.isSubModel && item.model.isEnabled
- );
-
- const preferredModel =
- preferredModelRef
- ? compatibleModels.find(
- (item) =>
- item.model.id === preferredModelRef ||
- item.model.modelId === preferredModelRef,
- )
- : null;
-
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
-
- let selectedModel =
- preferredModel && (!hasVisionInputs || supportsVision(preferredModel.model))
- ? preferredModel
- : hasVisionInputs &&
- agentModel &&
- supportsVision(agentModel.model)
- ? agentModel
- : hasVisionInputs
- ? compatibleModels.find((item) => supportsVision(item.model))
- : agentModel || compatibleModels[0];
-
- if (!selectedModel) {
- selectedModel = compatibleModels[0];
+ const compatibleModels = allModels.filter((item) => {
+ const descriptor = {...item.model, providerType: item.provider.type};
+ return !item.model.isSubModel && item.model.isEnabled &&
+ (hasVisionInputs ? isCanvasVisionModel(descriptor) : isCanvasLanguageModel(descriptor));
+ });
+ const requestedRef = preferredModelRef === "auto" ? "" : preferredModelRef;
+ const preferredModel = requestedRef ? compatibleModels.find(
+ item => item.model.id === requestedRef || item.model.modelId === requestedRef
+ ) : undefined;
+ if (requestedRef && !preferredModel) {
+ return NextResponse.json({ success: false, error: "所选模型不可用或不支持当前分析输入,请重新选择分析模型" }, { status: 400 });
}
-
+ const selectedModel = preferredModel || compatibleModels.find(item => item.model.id === agentModel?.model.id) || compatibleModels[0];
if (!selectedModel) {
- return NextResponse.json(
- { success: false, error: "当前站点没有可用的分析模型" },
- { status: 500 }
- );
+ return NextResponse.json({ success: false, error: hasVisionInputs ? "当前没有可用的视觉理解模型" : "当前没有可用的文字分析模型" }, { status: 400 });
}
const useVision = hasVisionInputs && supportsVision(selectedModel.model);
diff --git a/app/globals.css b/app/globals.css
index 9291d28d4..db0086be5 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -3,6 +3,7 @@
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
+@source '../components/canvas/**/*.tsx';
@source not '../services/pascal-editor';
/* custom variant for setting dark mode programmatically */
diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx
index 8594cec7a..00ba13273 100644
--- a/components/canvas/LiblibVideoCanvasReplica.tsx
+++ b/components/canvas/LiblibVideoCanvasReplica.tsx
@@ -1,6 +1,9 @@
"use client";
+import { filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching";
import {
+ ScanText,
+ AudioLines,
ArrowUp,
Bell,
BookOpen,
@@ -57,6 +60,7 @@ import {
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
+ type CSSProperties,
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
@@ -127,6 +131,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"
@@ -413,7 +421,7 @@ type CanvasModelOption = {
} | null;
};
-type CanvasModelKind = "image" | "video" | "multimodal" | "any";
+
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "director" | "audio" | "script" | "legacy-script";
type CanvasCreateKind = AddModuleKind;
@@ -797,7 +805,7 @@ const STARTER_CARDS = [
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
},
{
- title: "首帧图生视频",
+ title: "全能参考生视频",
subtitle: "参考图到成片",
coverUrl:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
@@ -997,176 +1005,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;
@@ -1221,7 +1059,6 @@ 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;
@@ -3427,6 +3264,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 +3343,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 ||
@@ -3807,7 +3655,7 @@ function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode)
if (mode === "text-to-video") return [];
if (mode === "first-frame") return images.slice(0, 1);
if (mode === "first-last-frame") return images.slice(0, 2);
- return images.slice(0, 4);
+ return images;
}
function getCanvasModelRef(model: CanvasModelOption) {
@@ -3833,18 +3681,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 +3756,8 @@ function resolveVideoCanvasBillingDisplay(params: {
};
}
+ if (!params.membershipSummary) return fallback;
+
if (pointsConfig.enabled !== true) {
return {
label: "免费",
@@ -3963,7 +3802,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(
@@ -4436,10 +4275,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;
@@ -4799,7 +4639,7 @@ 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",
@@ -4881,6 +4721,8 @@ export function LiblibVideoCanvasReplica({
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",
@@ -5345,6 +5187,7 @@ export function LiblibVideoCanvasReplica({
.filter((item) => item.inputType === "audio")
.map((item) => getNodeReferenceMediaUrl(item))
);
+ if (functionKey === "audio-to-video" && !audioUrls.length) { toast.error("音频生视频需要连接音频素材"); return; }
const needsMedia = requiresMedia(tool) || functionRequiresMedia(definition);
if (needsMedia && imageUrls.length === 0 && videoUrls.length === 0 && audioUrls.length === 0) {
toast.error(`请先为「${node.title}」连接图片、视频或音频素材`);
@@ -5527,6 +5370,7 @@ export function LiblibVideoCanvasReplica({
try {
const response = await videoGenerationService.generateVideo({
images: referenceImages,
+ audioUrls: functionKey === "audio-to-video" ? audioUrls : undefined,
mode: requestedMode,
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, modelOverride),
prompt: finalPrompt,
@@ -5656,6 +5500,7 @@ export function LiblibVideoCanvasReplica({
if (!generatedUrl && !response.success) throw new Error(response.error || "抠图处理失败");
} else if (route === "canvas-generate-image" || definition.kind === "image-edit" || definition.kind === "utility") {
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: isWideGeneration ? "16:9" : undefined,
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
@@ -5799,31 +5644,18 @@ export function LiblibVideoCanvasReplica({
const addTargetModule = useCallback(
(kind: AddModuleKind, point?: { x: number; y: number }) => {
- const viewportCenter = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
- const compactSpawn = window.innerWidth < 900 || window.innerHeight < 640;
- const useViewportTopLeftSpawn =
- kind === "text" ||
- kind === "image" ||
- kind === "video" ||
- kind === "video-clip" ||
- kind === "director" ||
- kind === "audio" ||
- kind === "script" ||
- kind === "legacy-script";
- const offset = compactSpawn || useViewportTopLeftSpawn ? { x: 0, y: 0 } : TARGET_MODULE_CENTER_OFFSETS[kind];
- const sameKindCount = nodes.filter((node) => getTargetModuleKind(node) === kind).length;
- const spawnOffset = useViewportTopLeftSpawn ? 0 : sameKindCount * 28;
- const center = point || {
- x: viewportCenter.x + offset.x + spawnOffset,
- y: viewportCenter.y + offset.y + spawnOffset,
- };
+ const viewportCenter = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2 - 90);
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
- const useTopLeftSpawn = useViewportTopLeftSpawn && !point;
- const node = {
- ...created,
- x: Math.round(useTopLeftSpawn ? center.x : center.x - created.width / 2),
- y: Math.round(useTopLeftSpawn ? center.y : center.y - created.height / 2),
- };
+ const center = point || viewportCenter;
+ const node = {...created, x: Math.round(center.x-created.width/2), y: Math.round(center.y-created.height/2)};
+ if (!point) {
+ for (let attempt=0; attempt<=nodes.length; attempt++) {
+ const overlap = nodes.find(other => node.x < other.x+other.width+40 && node.x+node.width+40 > other.x && node.y < other.y+other.height+40 && node.y+node.height+40 > other.y);
+ if (!overlap) break;
+ node.x = Math.round(overlap.x+overlap.width+80);
+ }
+ setViewport(current => ({...current, x:Math.round(window.innerWidth/2-(node.x+node.width/2)*current.zoom), y:Math.round(window.innerHeight/2-90-(node.y+node.height/2)*current.zoom)}));
+ }
addNodes([node], []);
setActivePanel(null);
setCanvasCommand(null);
@@ -6856,6 +6688,7 @@ export function LiblibVideoCanvasReplica({
if (shouldGenerate) {
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
@@ -6974,6 +6807,7 @@ export function LiblibVideoCanvasReplica({
if (shouldGenerate) {
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
@@ -7111,6 +6945,7 @@ export function LiblibVideoCanvasReplica({
if (!generatedUrl && !response.success) toast.error(response.error || "抠图处理失败");
} else if (definition.route === "canvas-generate-image") {
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
@@ -8007,6 +7842,7 @@ export function LiblibVideoCanvasReplica({
throw new Error("视频画布功能未启用,请先在配置中开启");
}
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: isWideGeneration ? "16:9" : payload.settings.ratio,
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: isWideGeneration ? 1 : count,
@@ -8072,6 +7908,15 @@ export function LiblibVideoCanvasReplica({
[nodes, videoCanvasFunctionConfig]
);
+ const persistVideoComposerSettings = useCallback((nodeId: string, settings: ComposerSettings, mode: VideoGenerationMode) => {
+ setNodes(current => current.map(node => {
+ if (node.id !== nodeId) return node;
+ const params = getNodeParams(node);
+ if (params.mode === mode && params.model === settings.model && JSON.stringify(params.settings) === JSON.stringify(settings)) return node;
+ return {...node, draftValues: {...node.draftValues, params: {...params, mode, model: settings.model, ratio: settings.ratio, resolution: settings.resolution, duration: normalizeVideoDurationValue(settings.duration), settings}}};
+ }));
+ }, []);
+
const submitVideoTargetComposer = useCallback(
async (
nodeId: string,
@@ -8636,7 +8481,7 @@ export function LiblibVideoCanvasReplica({
count: "1个",
duration: `${duration}s`,
model: "seedance-2.0-vip",
- modelLabel: "Seedance 2.0 VIP",
+ modelLabel: "",
panorama: referenceImage ? "首帧生成视频" : "文生视频",
ratio: "16:9",
resolution: "720P",
@@ -8669,7 +8514,7 @@ export function LiblibVideoCanvasReplica({
imagePrompt: row.imagePrompt,
mode: referenceImage ? "first-frame" : "text-to-video",
model: "seedance-2.0-vip",
- modelLabel: "Seedance 2.0 VIP",
+ modelLabel: "",
motionPrompt: row.motionPrompt,
prompt,
ratio: "16:9",
@@ -8858,129 +8703,30 @@ export function LiblibVideoCanvasReplica({
[addNodes, nodes]
);
- const generateVideoHdUpscaleNode = useCallback(
- (nodeId: string) => {
- const hdNode = nodes.find((item) => item.id === nodeId);
- if (!hdNode) return;
- const sourceNode = nodes.find((item) => item.id === getDraftString(hdNode, "sourceVideoNodeId"));
- const mediaUrl =
- getNodeReferenceMediaUrl(sourceNode) ||
- getDraftString(hdNode, "sourceUrl") ||
- getNodeMediaUrl(hdNode) ||
- SCRIPT_VIDEO_REFERENCE_URL;
+ const generateVideoHdUpscaleNode = useCallback((nodeId: string) => {
+ const error = "当前没有已接通的视频超清模型,请先配置视频超分服务。";
+ setNodes(current => current.map(node => node.id === nodeId ? {...node, draftValues:{...node.draftValues,generated:false,generationStatus:"failed",generationError:error}} : node));
+ toast.error(error);
+ }, []);
- setNodes((current) =>
- current.map((item) =>
- item.id === nodeId
- ? {
- ...item,
- draftValues: {
- ...(item.draftValues || {}),
- generated: false,
- generationProgress: 8,
- generationStatus: "pending",
- mediaUrl: "",
- url: [],
- },
- }
- : item
- )
- );
- setSelectedNodeId(nodeId);
- window.setTimeout(() => {
- setNodes((current) =>
- current.map((item) =>
- item.id === nodeId && getDraftString(item, "generationStatus") === "pending"
- ? {
- ...item,
- subtitle: "生成结果",
- draftValues: {
- ...(item.draftValues || {}),
- generated: true,
- generationProgress: 100,
- generationStatus: "complete",
- mediaType: "video",
- mediaUrl,
- url: [mediaUrl],
- },
- }
- : item
- )
- );
- }, 1800);
- },
- [nodes]
- );
-
- const applyVideoCropSelection = useCallback(
- (nodeId: string) => {
- const videoNode = nodes.find((item) => item.id === nodeId);
- if (!videoNode || videoCropGenerateStates[nodeId]?.status === "loading") return;
- const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode);
- if (!sourceUrl) {
- toast.warning("视频尚未加载完成,请稍后再试");
- return;
- }
-
- setVideoCropGenerateStates((current) => ({
- ...current,
- [nodeId]: { progress: 37, status: "loading" },
- }));
- toast.info("正在裁剪视频,请稍候…");
-
- window.setTimeout(() => {
- setVideoCropGenerateStates((current) =>
- current[nodeId]?.status === "loading"
- ? { ...current, [nodeId]: { progress: 68, status: "loading" } }
- : current
- );
- }, 650);
-
- window.setTimeout(() => {
- const existingIndex = nodes.filter(
- (item) =>
- getDraftString(item, "action") === "VIDEO_CROP" &&
- getDraftString(item, "sourceVideoNodeId") === videoNode.id
- ).length;
- const cropNode = createVideoCropResultNode({ existingIndex, sourceNode: videoNode });
-
- addNodes([cropNode], [{ id: makeId("edge"), source: videoNode.id, target: cropNode.id }]);
- setSelectedNodeId(cropNode.id);
- setSelectedEdgeId(null);
- setActiveVideoToolbarMode(null);
- setVideoCropGenerateStates((current) => {
- const next = { ...current };
- delete next[nodeId];
- return next;
- });
- toast.success("已创建裁剪视频节点并开始上传");
-
- window.setTimeout(() => {
- setNodes((current) =>
- current.map((item) =>
- item.id === cropNode.id && getDraftString(item, "generationStatus") === "pending"
- ? {
- ...item,
- subtitle: "裁剪结果",
- draftValues: {
- ...(item.draftValues || {}),
- generated: true,
- generationProgress: 100,
- generationStatus: "complete",
- mediaType: "video",
- mediaUrl: sourceUrl,
- uploadStatus: "complete",
- url: [sourceUrl],
- },
- }
- : item
- )
- );
- }, 1800);
- }, 1300);
- },
- [addNodes, nodes, videoCropGenerateStates]
- );
+ const applyVideoCropSelection = useCallback(async (nodeId: string) => {
+ const source = nodes.find(node => node.id === nodeId);
+ if (!source || videoCropGenerateStates[nodeId]?.status === "loading") return;
+ const url = getNodeReferenceMediaUrl(source) || getNodeMediaUrl(source);
+ if (!url) { toast.error("请先上传视频"); return; }
+ setVideoCropGenerateStates(current => ({...current,[nodeId]:{progress:0,status:"loading"}}));
+ try {
+ const response = await fetch("/api/canvas/video/process",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({operation:"crop",url,rect:source.draftValues?.cropRect || VIDEO_CROP_RECT})});
+ const result = await response.json();
+ if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "视频裁剪失败");
+ const created = createVideoCropResultNode({existingIndex:nodes.filter(item=>getDraftString(item,"action")==="VIDEO_CROP").length,sourceNode:source});
+ const cropNode = {...created,subtitle:"裁剪结果",draftValues:{...created.draftValues,generated:true,generationStatus:"complete",generationProgress:100,mediaUrl:result.data.url,url:[result.data.url],uploadStatus:"complete",outputSizeLabel:`${result.data.width} × ${result.data.height}`,durationSeconds:result.data.duration}};
+ addNodes([cropNode],[{id:makeId("edge"),source:nodeId,target:cropNode.id}]);
+ setSelectedNodeId(cropNode.id); setActiveVideoToolbarMode(null);
+ toast.success("视频裁剪完成");
+ } catch(error) { toast.error(error instanceof Error ? error.message : "视频裁剪失败"); }
+ finally { setVideoCropGenerateStates(current=>{const next={...current};delete next[nodeId];return next;}); }
+ },[addNodes,nodes,videoCropGenerateStates]);
const commitSubtitleEraseRegions = useCallback(
(nodeId: string, nextRegions: SubtitleEraseRegion[]) => {
@@ -9043,62 +8789,13 @@ export function LiblibVideoCanvasReplica({
[subtitleEraseRegionHistory, subtitleEraseRegions]
);
- const applySubtitleEraseSelection = useCallback(
- (nodeId: string, mode: SubtitleEraseMode = subtitleEraseMode) => {
- const videoNode = nodes.find((item) => item.id === nodeId);
- if (!videoNode) return;
- const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode);
- if (!sourceUrl) {
- toast.warning("视频尚未加载完成,请稍后再试");
- return;
- }
- const regions = subtitleEraseRegions[nodeId] || [];
- if (mode === "region" && !regions.length) {
- toast.warning("请先框选需要擦除的字幕区域");
- return;
- }
- const existingIndex = nodes.filter(
- (item) =>
- getDraftString(item, "action") === "VIDEO_SUBTITLE_ERASE" &&
- getDraftString(item, "sourceVideoNodeId") === videoNode.id
- ).length;
- const eraseNode = createVideoSubtitleEraseNode({ existingIndex, mode, regions, sourceNode: videoNode });
-
- setNodes((current) => [...current, eraseNode]);
- setEdges((current) => [...current, { id: makeId("edge"), source: videoNode.id, target: eraseNode.id }]);
- setSelectedNodeId(videoNode.id);
- setSelectedEdgeId(null);
- setActiveVideoToolbarMode(null);
-
- window.setTimeout(() => {
- setNodes((current) =>
- current.map((item) =>
- item.id === eraseNode.id && getDraftString(item, "generationStatus") === "pending"
- ? {
- ...item,
- height: videoNode.height,
- subtitle: "去字幕结果",
- width: videoNode.width,
- draftValues: {
- ...(item.draftValues || {}),
- generated: true,
- generationProgress: 100,
- generationStatus: "complete",
- mediaType: "video",
- mediaUrl: sourceUrl,
- url: [sourceUrl],
- },
- }
- : item
- )
- );
- }, VIDEO_SUBTITLE_ERASE_SIMULATED_MS);
- },
- [nodes, subtitleEraseMode, subtitleEraseRegions]
- );
+ const applySubtitleEraseSelection = useCallback((_nodeId: string, _mode: SubtitleEraseMode = subtitleEraseMode) => {
+ toast.error("当前没有已接通的视频字幕擦除模型,暂不能处理。所选区域会保留。");
+ },[subtitleEraseMode]);
const splitVideoAudioTrack = useCallback(
- (nodeId: string, mode: VideoAudioSplitMode) => {
+ async (nodeId: string, mode: VideoAudioSplitMode) => {
+ if (mode !== "audio-video") { toast.error("人声与背景音乐分离需要专用音频分离模型,当前尚未接通。"); return; }
const videoNode = nodes.find((item) => item.id === nodeId);
if (!videoNode || videoAudioSplitStates[nodeId]?.status === "loading") return;
const splitCopy: Record<
@@ -9161,12 +8858,15 @@ export function LiblibVideoCanvasReplica({
setActiveVideoToolbarMode(null);
toast.info(`${copy.processingLabel}处理中,请稍候…`);
- window.setTimeout(() => {
+ try {
+ const response = await fetch("/api/canvas/video/process", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({operation:"audio-video",url:sourceUrl})});
+ const result = await response.json();
+ if (!response.ok || !result.success || !result.data?.audioUrl || !result.data?.url) throw new Error(result.error || "音视频分离失败");
const audioCreated = createMediaResourceNode({
action: copy.audioAction,
height: 148,
mediaType: "audio",
- mediaUrl: AUDIO_SOURCE_URL,
+ mediaUrl: result.data.audioUrl,
title: `${baseTitle}_${copy.audioSuffix}`,
width: 350,
x: rightX,
@@ -9188,7 +8888,7 @@ export function LiblibVideoCanvasReplica({
action: copy.silentAction,
height: 350,
mediaType: "video",
- mediaUrl: sourceUrl,
+ mediaUrl: result.data.url,
title: `${baseTitle}_无声`,
width: 350,
x: rightX,
@@ -9226,7 +8926,8 @@ export function LiblibVideoCanvasReplica({
delete next[nodeId];
return next;
});
- }, 6400);
+ } catch(error) { toast.error(error instanceof Error ? error.message : "音视频分离失败"); }
+ finally { setVideoAudioSplitStates(current=>{const next={...current};delete next[nodeId];return next;}); }
},
[addNodes, nodes, videoAudioSplitStates]
);
@@ -9507,6 +9208,7 @@ export function LiblibVideoCanvasReplica({
videoCanvasFunctionConfig,
mode === "audio-to-video" ? "audio-to-video" : "first-frame-video",
);
+ if (mode === "audio-to-video" && !audioUrls.length) throw new Error("音频生视频需要连接音频素材");
const requestedMode: VideoGenerationMode = "first-frame";
const referenceImages = selectVideoReferenceImages(imageUrls, requestedMode);
if (referenceImages.length < 1) {
@@ -9574,6 +9276,8 @@ export function LiblibVideoCanvasReplica({
const response = await videoGenerationService.generateVideo({
duration,
images: referenceImages,
+ audioUrls: mode === "audio-to-video" ? audioUrls : undefined,
+ generateAudio: settings?.audio !== "关闭",
mode: requestedMode,
preferredModelRef,
prompt,
@@ -9791,6 +9495,7 @@ export function LiblibVideoCanvasReplica({
if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败");
} else {
const response = await imageGenerationService.generateImage({
+ allowFailover: false,
aspectRatio: isWideImageGenerationFunction(functionKey) ? "16:9" : undefined,
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
@@ -9936,7 +9641,10 @@ export function LiblibVideoCanvasReplica({
if (workflow) {
addNodes(workflow.nodes, workflow.edges);
setSelectedNodeId(workflow.selectedId);
- setViewport(PRESET_VIEWPORT);
+ const minX=Math.min(...workflow.nodes.map(n=>n.x)), minY=Math.min(...workflow.nodes.map(n=>n.y));
+ const maxX=Math.max(...workflow.nodes.map(n=>n.x+n.width)), maxY=Math.max(...workflow.nodes.map(n=>n.y+n.height));
+ const zoom=Math.max(0.1, Math.min(0.85, (window.innerWidth-96)/(maxX-minX), (window.innerHeight-340)/(maxY-minY)));
+ setViewport({zoom,x:Math.round(window.innerWidth/2-(minX+maxX)/2*zoom),y:Math.round(90-minY*zoom)});
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
@@ -10254,6 +9962,12 @@ export function LiblibVideoCanvasReplica({
const startNodeDrag = useCallback(
(event: ReactPointerEvent, node: CloneNode) => {
event.stopPropagation();
+ if (panMode || spacePanRef.current) {
+ if (event.button !== 0) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = {kind:"canvas",startX:event.clientX,startY:event.clientY,viewportX:viewport.x,viewportY:viewport.y};
+ return;
+ }
if (markSelectionTargetId) {
event.preventDefault();
const mediaType = getDraftString(node, "mediaType") || node.inputType;
@@ -10380,6 +10094,7 @@ export function LiblibVideoCanvasReplica({
},
[
analyzeVideoCanvasMark,
+ panMode, viewport.x, viewport.y,
connectExistingNodes,
getCanvasPoint,
lineConnectState,
@@ -10412,6 +10127,7 @@ export function LiblibVideoCanvasReplica({
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
+ if (event.code === "Space") { event.preventDefault(); spacePanRef.current = true; return; }
if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") {
event.preventDefault();
setCanvasCommand(null);
@@ -10455,8 +10171,12 @@ export function LiblibVideoCanvasReplica({
setMarkAnalysisState(null);
}
};
+ const releasePan = () => { spacePanRef.current = false; };
+ const onKeyUp = (event: KeyboardEvent) => { if (event.code === "Space") releasePan(); };
window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ window.addEventListener("blur", releasePan);
+ return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", releasePan); };
}, [createLineConnectState, deleteNode, deleteSelectedEdge, nodes, selectedEdgeId, selectedNodeId]);
const onPointerMove = useCallback(
@@ -10860,6 +10580,7 @@ export function LiblibVideoCanvasReplica({
return (
-
+
{lineConnectState ? (
{nodes.length === 0 && !activePanel ? (
-
-
-
-
双击画布
-
自由生成节点
+
+
-
- {STARTER_CARDS.map((card) => {
- const cover = card.coverUrl || capturedWorkflows[0]?.cover || "";
- return (
-
- );
+
+ {STARTER_CARDS.map((card, index) => {
+ const Icon = [ScanText, UserRound, ImageIcon, AudioLines][index];
+ const gradients = ["linear-gradient(#173C27,#B4C490)", "linear-gradient(#B62927,#FF9292)", "linear-gradient(#274156,#B4D3E2)", "linear-gradient(#332248,#B7A1CF)"];
+ return ;
})}
@@ -11175,13 +10871,13 @@ export function LiblibVideoCanvasReplica({
/>
{activeVideoToolbarMode === "crop" ? (
<>
-
+
setNodes(current => current.map(node => node.id === selectedVideoMediaNode.id ? {...node,draftValues:{...node.draftValues,cropRect:rect}} : node))} />
setPanMode(current => !current)}
activePanel={activePanel}
onOpen={(panel) => {
setCanvasCommand(null);
@@ -11519,6 +11218,7 @@ export function LiblibVideoCanvasReplica({
{inspectorNode ? (
edge.target === inspectorNode.id).length}
onAddReference={(node) => addReferenceModulesForTool(node.id)}
@@ -11915,14 +11615,18 @@ function getToolbarButtonLabel(item: (typeof TOOLBAR_ITEMS)[number]) {
function LeftToolbar({
activePanel,
+ panMode,
+ onTogglePan,
onOpen,
}: {
activePanel: ActivePanel;
+ panMode: boolean;
+ onTogglePan: () => void;
onOpen: (panel: Exclude) => void;
}) {
return (
);
})}
@@ -13164,51 +12869,23 @@ function VideoInlineActionBar({
);
}
-function VideoCropSelectionOverlay({
- anchor,
- node,
-}: {
- anchor: PresetControlAnchor;
- node: CloneNode;
-}) {
- const nodeWidth = Math.max(1, node.width * anchor.zoom);
- const nodeHeight = Math.max(1, node.height * anchor.zoom);
- const cardTop = anchor.topY + 32 * anchor.zoom;
- const cardHeight = Math.max(1, nodeHeight - 32 * anchor.zoom);
- const cardLeft = anchor.centerX - nodeWidth / 2;
- const cropLeft = cardLeft + nodeWidth * VIDEO_CROP_RECT.x;
- const cropTop = cardTop + cardHeight * VIDEO_CROP_RECT.y;
- const cropWidth = nodeWidth * VIDEO_CROP_RECT.width;
- const cropHeight = cardHeight * VIDEO_CROP_RECT.height;
- const handlePoints = [
- ["left-0 top-0 -translate-x-1/2 -translate-y-1/2"],
- ["left-1/2 top-0 -translate-x-1/2 -translate-y-1/2"],
- ["right-0 top-0 translate-x-1/2 -translate-y-1/2"],
- ["right-0 top-1/2 -translate-y-1/2 translate-x-1/2"],
- ["right-0 bottom-0 translate-x-1/2 translate-y-1/2"],
- ["left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2"],
- ["left-0 bottom-0 -translate-x-1/2 translate-y-1/2"],
- ["left-0 top-1/2 -translate-x-1/2 -translate-y-1/2"],
- ];
-
- return (
-
-
-
- {VIDEO_CROP_INLINE_LABEL}
-
- {handlePoints.map(([className]) => (
-
- ))}
-
+function VideoCropSelectionOverlay({anchor,node,onChange}:{anchor:PresetControlAnchor;node:CloneNode;onChange:(rect:GridCropRect)=>void}) {
+ const box=getVideoCardOverlayRect(anchor,node);
+ const saved=(node.draftValues?.cropRect || VIDEO_CROP_RECT) as GridCropRect;
+ const [drawing,setDrawing]=useState<{x:number;y:number;endX:number;endY:number}|null>(null);
+ const rect=drawing ? {x:Math.min(drawing.x,drawing.endX),y:Math.min(drawing.y,drawing.endY),width:Math.abs(drawing.endX-drawing.x),height:Math.abs(drawing.endY-drawing.y)} : saved;
+ const point=(event:ReactPointerEvent
)=>({x:clampUnit((event.clientX-box.left)/box.width),y:clampUnit((event.clientY-box.top)/box.height)});
+ return {event.stopPropagation();event.currentTarget.setPointerCapture(event.pointerId);const p=point(event);setDrawing({...p,endX:p.x,endY:p.y});}}
+ onPointerMove={event=>{if(!drawing)return;event.stopPropagation();const p=point(event);setDrawing({...drawing,endX:p.x,endY:p.y});}}
+ onPointerUp={event=>{event.stopPropagation();if(drawing&&rect.width>.02&&rect.height>.02)onChange(rect);setDrawing(null);event.currentTarget.releasePointerCapture(event.pointerId);}}
+ onPointerCancel={()=>setDrawing(null)}>
+
+ 拖动框选裁剪区域
+
+
- );
+
;
}
function clampUnit(value: number) {
@@ -14060,7 +13737,7 @@ function PresetWorkflowControls({
defaultModel={getFunctionModelValue(functionConfig, definition)}
functionDefinition={definition}
membershipSummary={membershipSummary}
- modelKind="image"
+ modelKind="image-edit"
modelOptions={modelOptions}
nodes={nodes}
onSubmit={onComposerSubmit}
@@ -14266,9 +13943,7 @@ function AudioTargetModuleComposer({ anchor }: { anchor: PresetControlAnchor })
const compactComposer = viewportWidth < 980 || viewportHeight < 620;
const composerWidth = compactComposer ? Math.max(320, viewportWidth - 24) : 660;
const composerHeight = settingsOpen ? 565 : 189;
- const composerTop = compactComposer
- ? Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 12))
- : anchor.bottomY + 12;
+ const composerTop = Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 76));
const insertToken = useCallback((token: string) => {
setText((current) => (current ? `${current} ${token}` : token));
@@ -14848,6 +14523,7 @@ function VideoTargetModuleComposer({
onRemoveReference,
onRemoveRole,
onPromptChange,
+ onSettingsChange,
onStartMarkSelection,
onStartReferenceSelection,
onSubmit,
@@ -14869,6 +14545,7 @@ function VideoTargetModuleComposer({
onRemoveReference: (sourceId: string) => void;
onRemoveRole: (sourceId: string) => void;
onPromptChange: (prompt: string) => void;
+ onSettingsChange: (nodeId: string, settings: ComposerSettings, mode: VideoGenerationMode) => void;
onStartMarkSelection: () => void;
onStartReferenceSelection: () => void;
onSubmit: (payload: VideoTargetComposerSubmitPayload) => void;
@@ -14909,7 +14586,7 @@ function VideoTargetModuleComposer({
functionConfig,
getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(resolveVideoComposerMode(initialTab))),
),
- modelLabel: String(paramsSettings.modelLabel || "").trim() || "Seedance 2.0 VIP",
+ modelLabel: "",
panorama: String(paramsSettings.panorama || "全能参考"),
ratio: formatVideoRatioLabel(params.ratio || paramsSettings.ratio),
resolution: formatVideoResolutionLabel(params.resolution || paramsSettings.resolution),
@@ -14931,6 +14608,7 @@ function VideoTargetModuleComposer({
} | null>(null);
const [settings, setSettings] = useState(initialSettings);
const activeMode = resolveVideoComposerMode(activeTab);
+ useEffect(() => { onSettingsChange(node.id, settings, activeMode); }, [node.id, settings, activeMode, onSettingsChange]);
const activeDefinition = getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(activeMode));
const activeModelFallback = normalizeModelRef(getFunctionModelValue(functionConfig, activeDefinition)) || "auto";
const showModelMenu = menu === "model";
@@ -14946,7 +14624,7 @@ function VideoTargetModuleComposer({
{ key: "text-to-video", label: "文生视频", enabled: referenceCount === 0 },
{ key: "reference-images", label: "全能参考", enabled: referenceCount >= 1 },
{ key: "first-frame", label: "图生视频", enabled: referenceCount >= 1 },
- { key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 1 },
+ { key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 2 },
{ key: "image-reference", label: "图片参考", enabled: referenceCount >= 1 },
];
const referenceThumbnails = imageSources
@@ -14960,6 +14638,22 @@ function VideoTargetModuleComposer({
setSettings(initialSettings);
}, [initialPrompt, initialTab, node.id]);
+ useEffect(() => {
+ const candidates = filterCanvasModels(modelOptions, "video", activeMode);
+ const normalized = normalizeModelRef(settings.model);
+ const selected = candidates.find(item => item.id === normalized || item.modelId === normalized);
+ const target = selected || ((!normalized || normalized === "auto") ? candidates[0] : undefined);
+ if (!target) return;
+ const supported = getCanvasVideoParameters(target, activeMode);
+ setSettings(current => {
+ const ratio = supported.ratios.length && !supported.ratios.includes(normalizeVideoRatioValue(current.ratio)) ? supported.ratios[0] : current.ratio;
+ const resolution = supported.resolutions.length && !supported.resolutions.includes(normalizeVideoResolutionValue(current.resolution)) ? supported.resolutions[0].toUpperCase() : current.resolution;
+ const duration = supported.durations.length && !supported.durations.includes(normalizeVideoDurationValue(current.duration)) ? `${supported.durations[0]}s` : current.duration;
+ return ratio === current.ratio && resolution === current.resolution && duration === current.duration ? current : {...current, ratio, resolution, duration};
+ });
+ }, [activeMode, modelOptions, settings.model, settings.ratio, settings.resolution, settings.duration]);
+
+
useEffect(() => {
if (!tabItems.find((item) => item.key === activeTab)?.enabled) {
setActiveTab(inferVideoComposerDefaultTab(referenceCount));
@@ -14995,30 +14689,7 @@ function VideoTargetModuleComposer({
settings.resolution,
],
);
- const estimatedBillingDisplay = useMemo(
- () =>
- resolveVideoCanvasBillingDisplay({
- duration: normalizeVideoDurationValue(settings.duration),
- functionDefinition: activeDefinition,
- modelKind: "video",
- modelOptions,
- modelRef: settings.model,
- resolution: normalizeVideoResolutionValue(settings.resolution),
- }),
- [
- activeDefinition,
- modelOptions,
- settings.duration,
- settings.model,
- settings.resolution,
- ],
- );
- const visibleBillingDisplay = /^\d+$/.test(estimatedBillingDisplay.label)
- ? estimatedBillingDisplay
- : {
- label: "135",
- message: billingDisplay.message || "当前视频生成预计消耗 135 积分。",
- };
+ const visibleBillingDisplay = billingDisplay;
const appendMotionPrompt = useCallback(
(snippet: string) => {
const trimmedSnippet = snippet.trim();
@@ -15044,9 +14715,7 @@ function VideoTargetModuleComposer({
const baseComposerHeight = (compactComposer ? 210 : 191) + attachmentComposerHeight;
const advancedComposerHeight = advancedSettingsOpen ? 116 : 0;
const composerHeight = baseComposerHeight + advancedComposerHeight;
- const composerTop = compactComposer
- ? Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 12))
- : anchor.bottomY + 12;
+ const composerTop = Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 76));
const allQuickActionItems: Array<{ icon: LucideIcon; key: "reference" | "mark" | "effects" | "role" | "motion"; label: string }> = [
{ icon: Layers, key: "reference", label: "参考" },
{ icon: BoxSelect, key: "mark", label: "标记" },
@@ -15086,7 +14755,7 @@ function VideoTargetModuleComposer({
data-canvas-overlay="true"
style={{
height: composerHeight,
- left: compactComposer ? 12 : anchor.centerX,
+ left: compactComposer ? 12 : Math.max(composerWidth / 2 + 12, Math.min(anchor.centerX, viewportWidth - composerWidth / 2 - 12)),
top: composerTop,
transform: compactComposer ? "none" : "translateX(-50%)",
transformOrigin: "top left",
@@ -15100,6 +14769,7 @@ function VideoTargetModuleComposer({
{showModelMenu ? (
{
@@ -15127,6 +14797,8 @@ function VideoTargetModuleComposer({
setSettings((current) => ({ ...current, ...updates }))}
/>
) : null}
@@ -15666,7 +15338,7 @@ function AudioToVideoComposerPanel({
count: "1个",
duration: "5s",
model: normalizeModelRef(defaultModel) || "auto",
- modelLabel: "Seedance 2.0 VIP",
+ modelLabel: "",
panorama: "全景",
ratio: "16:9",
resolution: "720P",
@@ -15736,6 +15408,7 @@ function AudioToVideoComposerPanel({
{showModelMenu ? (
{
@@ -15750,7 +15423,9 @@ function AudioToVideoComposerPanel({
) : null}
{showSettingsMenu ? (
setSettings((current) => ({ ...current, ...updates }))}
/>
) : null}
@@ -16648,6 +16323,10 @@ function SceneToolbarPopover({
const nineGridOptions = [
"多机位九宫格",
"剧情推演四宫格",
+ "角色脸部三视图",
+ "角色设定图",
+ "场景设定图",
+ "产品设定图",
"25宫格连贯分镜",
"电影级光影校正",
"角色三视图生成",
@@ -16699,21 +16378,21 @@ function SceneToolbarPopover({
if (menu === "grid") {
return (
{nineGridOptions.map((option) => (
@@ -17329,11 +17008,10 @@ function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void
}
function getVideoComposerModelLabel(settings: Pick
, modelOptions: CanvasModelOption[]) {
- const configuredLabel = settings.modelLabel?.trim();
- if (configuredLabel) return configuredLabel;
const normalized = normalizeModelRef(settings.model);
- if (!normalized) return "Seedance 2.0 VIP";
- return getCanvasModelDisplayName(settings.model, modelOptions);
+ if (!normalized || normalized === "auto") return "自动模型";
+ const model = modelOptions.find(item => item.id === normalized || item.modelId === normalized);
+ return model ? model.displayName || model.modelId || model.id : "模型不可用";
}
function getVideoComposerModelShortLabel(label: string) {
@@ -17344,7 +17022,7 @@ function getVideoComposerModelShortLabel(label: string) {
if (normalized === "Seedance1.5 Pro") return "1.5 Pro";
if (normalized === "Seedance 1.0 Pro") return "1.0 Pro";
if (normalized === "Seedance 1.0 Lite") return "1.0 Lite";
- return normalized || "2.0";
+ return normalized || "自动模型";
}
function normalizeComposerBoolean(value: unknown, fallback = true) {
@@ -17371,20 +17049,36 @@ function formatVideoParameterSummary(settings: Pick void;
}) {
+ const candidates = filterCanvasModels(modelOptions, audioRequired ? "audio-video" : "video", mode);
+ const options = [
+ { label: "自动模型", modelRef: "auto", description: "按当前模式和生成参数选择可用模型", duration: "", disabled: candidates.length === 0 },
+ ...candidates.map(model => ({
+ label: model.displayName || model.modelId || model.id,
+ modelRef: model.id,
+ description: model.providerName || "视频生成",
+ duration: getCanvasVideoModes(model).map(mode => ({"text-to-video":"文生", "first-frame":"首帧", "first-last-frame":"首尾帧", "reference-images":"多参考"})[mode]).join(" / "),
+ disabled: false,
+ })),
+ ];
return (
+ {candidates.length === 0 ?
暂无可用的视频模型,请在模型设置中启用视频模型。
: null}
- {LIBLIB_VIDEO_MODEL_OPTIONS.map((option, index) => {
+ {options.map((option, index) => {
const selected = currentLabel === option.label || (!currentLabel && index === 0);
const disabled = "disabled" in option && option.disabled === true;
return (
@@ -17396,8 +17090,8 @@ function VideoModelPopover({
)}
data-selected={selected ? "true" : "false"}
disabled={disabled}
- key={option.label}
- onClick={() => onSelect({ label: option.label, modelRef: index === 0 ? "auto" : undefined })}
+ key={option.modelRef}
+ onClick={() => onSelect({ label: option.label, modelRef: option.modelRef })}
type="button"
>
@@ -17473,117 +17167,37 @@ function VideoModePopover({
}
function VideoParameterEditor({
- leftOffset = 217,
- onChange,
- settings,
+ leftOffset = 217, onChange, settings, modelOptions, mode, audioRequired = false,
}: {
+ audioRequired?: boolean;
leftOffset?: number;
onChange: (updates: Partial) => void;
settings: ComposerSettings;
+ modelOptions: CanvasModelOption[];
+ mode?: VideoGenerationMode;
}) {
- const durationSeconds = normalizeVideoDurationValue(settings.duration);
- const clampedDurationSeconds = Math.min(15, Math.max(4, durationSeconds));
- const [durationDraft, setDurationDraft] = useState(String(clampedDurationSeconds));
- const durationProgress = ((clampedDurationSeconds - 4) / 11) * 100;
-
- useEffect(() => {
- setDurationDraft(String(clampedDurationSeconds));
- }, [clampedDurationSeconds]);
-
- const commitDuration = useCallback(() => {
- const parsed = Number.parseInt(durationDraft, 10);
- const nextDuration = Number.isFinite(parsed)
- ? Math.min(15, Math.max(4, parsed))
- : clampedDurationSeconds;
- setDurationDraft(String(nextDuration));
- onChange({ duration: `${nextDuration}s` });
- }, [clampedDurationSeconds, durationDraft, onChange]);
-
- const updateDurationFromSlider = useCallback(
- (value: string) => {
- const nextDuration = Math.min(15, Math.max(4, Number.parseInt(value, 10) || 5));
- setDurationDraft(String(nextDuration));
- onChange({ duration: `${nextDuration}s` });
- },
- [onChange]
- );
-
+ const candidates = filterCanvasModels(modelOptions, audioRequired ? "audio-video" : "video", mode);
+ const model = candidates.find(item => item.id === settings.model || item.modelId === settings.model) ||
+ (!normalizeModelRef(settings.model) || settings.model === "auto" ? candidates[0] : undefined);
+ const options = getCanvasVideoParameters(model, mode);
return (
-
- onChange({ ratio: label })}
- />
-
-
- onChange({ resolution })}
- />
-
-
-
-
-
- onChange({ audio })}
- />
-
-
- onChange({ count })}
- />
-
+ {!model ?
当前模式暂无可用模型,请先选择支持此模式的视频模型。
: <>
+
+ {options.ratios.length ? onChange({ratio})} /> : 跟随输入素材
}
+
+
+ {options.resolutions.length ? v.toUpperCase())} selected={settings.resolution || "720P"} onSelect={resolution => onChange({resolution})} /> : 由模型决定
}
+
+
+ {options.durations.length ? `${v}s`)} selected={settings.duration || "5s"} onSelect={duration => onChange({duration})} /> : 由模型决定
}
+
+ {options.supportsAudio ?
onChange({audio})} /> : null}
+
onChange({count})} />
+ >}
);
}
@@ -17644,7 +17258,9 @@ function VideoParameterRow({
function VideoRatioOptions({
onSelect,
selected,
+ options,
}: {
+ options: string[];
onSelect: (option: string) => void;
selected: string;
}) {
@@ -17660,9 +17276,9 @@ function VideoRatioOptions({
return (
- {VIDEO_RATIO_OPTIONS.map((option) => {
+ {options.map(label => ({label})).map((option) => {
const isSelected = selected === option.label;
- const iconSize = iconSizes[option.label];
+ const iconSize = iconSizes[option.label] || {width: 12, height: 12};
return (