diff --git a/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx b/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx index c79fba2c7..6251dd3fc 100644 --- a/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx +++ b/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx @@ -1,4 +1,5 @@ "use client"; +import { filterCanvasModels } from "@/lib/video-canvas/model-matching"; import Link from "next/link"; import { ArrowLeft, Plus, Trash2 } from "lucide-react"; @@ -24,10 +25,6 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { - isImageGenerationModelDescriptor, - isVideoModelDescriptor, -} from "@/lib/ai/model-classification"; import { fetcher } from "@/lib/utils"; import { getVideoCanvasFunctionBillingCopy, @@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) { return `${name}${id}`; } -function isImageModelOption(model: AiModelOption) { - return isImageGenerationModelDescriptor({ - providerType: model.providerType, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); -} - -function isVideoModelOption(model: AiModelOption) { - return isVideoModelDescriptor({ - providerType: model.providerType, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }); -} - -function filterModelsForFunctionKind( - models: AiModelOption[], - kind: VideoCanvasFunctionDefinition["kind"], -) { - const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model)); - let filtered = enabled; - if (kind === "image" || kind === "image-edit") { - filtered = enabled.filter(isImageModelOption); - } else if (kind === "video") { - filtered = enabled.filter(isVideoModelOption); - } else if (kind === "multimodal") { - filtered = enabled.filter( - (model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)), - ); - } - return filtered.length ? filtered : enabled; -} - function ModelSelect({ allowInherit = false, kind, + functionKey, models, onChange, value, }: { allowInherit?: boolean; kind: VideoCanvasFunctionDefinition["kind"]; + functionKey?: string; models: AiModelOption[]; onChange: (value: string) => void; value: string; }) { - const filteredModels = filterModelsForFunctionKind(models, kind); + const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text"; + const filteredModels = filterCanvasModels(models, modelKind); const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto"); const hasCurrent = normalizedValue === "auto" || @@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
- updateFunction(item.key, { model: value })} value={item.model || ""} - /> + /> :

{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}

}
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 })} - /> - - -
-
- - updateDurationFromSlider(event.target.value)} - step={1} - type="range" - value={clampedDurationSeconds} - /> -
- setDurationDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - commitDuration(); - } - }} - step={1} - type="number" - value={durationDraft} - /> - s -
-
- - 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 (