"use client"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { Video, Check, ChevronDown, ChevronUp, Sparkles, Camera, Clock, Settings2, Clapperboard, Monitor, Ratio, SlidersHorizontal, Volume2, VolumeX, } from "lucide-react"; import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; import { cn } from "@/lib/utils"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; // 视频生成模式类型 type VideoGenerationMode = | "text-to-video" | "first-frame" | "first-last-frame" | "reference-images"; // 运镜模式类型(与官方一致) type CameraMove = | "none" // 智能运镜 | "static" // 固定镜头 | "hitchcock-push" // 希区柯克推进 | "hitchcock-pull" // 希区柯克拉远 | "mechanical-arm" // 机械臂 | "dynamic-orbit" // 动感环绕 | "center-orbit" // 中心环绕 | "crane" // 起重机 | "super-pull" // 超级拉远 | "ccw-rotate" // 逆时针回旋 | "cw-rotate" // 顺时针回旋 | "handheld" // 手持运镜 | "fast-push-pull"; // 快速推拉 // 视频参数接口 export interface VideoGenerationParams { mode: VideoGenerationMode; resolution: "480p" | "720p" | "1080p"; ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive"; duration: number; generateAudio?: boolean; watermark: boolean; cameraFixed: boolean; cameraMove?: CameraMove; // 新增:运镜模式 fps?: number; seed?: number; returnLastFrame?: boolean; } interface VideoParamsBarProps { modelName?: string; initialParams?: Partial; onParamsChange?: (params: VideoGenerationParams) => void; inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器 inlineTheme?: "default" | "dark"; providerType?: string; capabilities?: VideoToolCapabilityContract | null; } const MODE_LABELS: Record = { "text-to-video": "文生视频", "first-last-frame": "首尾帧", "reference-images": "智能多帧", "first-frame": "主体参考", }; const RATIO_OPTIONS = [ { value: "21:9", label: "21:9" }, { value: "16:9", label: "16:9" }, { value: "4:3", label: "4:3" }, { value: "1:1", label: "1:1" }, { value: "3:4", label: "3:4" }, { value: "9:16", label: "9:16" }, ] as const; const RESOLUTION_OPTIONS = [ { value: "480p", label: "480P" }, { value: "720p", label: "720P" }, { value: "1080p", label: "1080P" }, ] as const; // 🎬 运镜模式选项(与官方一致) const CAMERA_MOVE_OPTIONS = [ { value: "none", label: "智能运镜" }, { value: "static", label: "固定镜头" }, { value: "hitchcock-push", label: "希区柯克推进" }, { value: "hitchcock-pull", label: "希区柯克拉远" }, { value: "mechanical-arm", label: "机械臂" }, { value: "dynamic-orbit", label: "动感环绕" }, { value: "center-orbit", label: "中心环绕" }, { value: "crane", label: "起重机" }, { value: "super-pull", label: "超级拉远" }, { value: "ccw-rotate", label: "逆时针回旋" }, { value: "cw-rotate", label: "顺时针回旋" }, { value: "handheld", label: "手持运镜" }, { value: "fast-push-pull", label: "快速推拉" }, ] as const; // 帧率选项 const FPS_OPTIONS = [ { value: 24, label: "24fps" }, { value: 30, label: "30fps" }, { value: 60, label: "60fps" }, ] as const; const STANDARD_MODE_OPTIONS: Array<{ value: VideoGenerationMode; label: string }> = [ { value: "text-to-video", label: "文生视频" }, { value: "first-last-frame", label: "首尾帧" }, { value: "reference-images", label: "智能多帧" }, { value: "first-frame", label: "主体参考" }, ]; const FAL_MODE_OPTIONS: Array<{ value: VideoGenerationMode; label: string }> = [ { value: "first-frame", label: "图生视频" }, { value: "first-last-frame", label: "首尾帧" }, ]; const RUXA_MODE_OPTIONS: Array<{ value: VideoGenerationMode; label: string }> = [ { value: "first-frame", label: "图生视频" }, ]; const FAL_RATIO_OPTIONS = [ { value: "16:9", label: "16:9" }, { value: "9:16", label: "9:16" }, { value: "1:1", label: "1:1" }, ] as const; const RUXA_RATIO_OPTIONS = FAL_RATIO_OPTIONS; const STANDARD_DURATION_OPTIONS = [5, 10]; const FAL_DURATION_OPTIONS = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; const RUXA_DURATION_OPTIONS = [5, 10]; const STANDARD_RATIO_SET = new Set(["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]); const FAL_RATIO_SET = new Set(["16:9", "9:16", "1:1"]); const RUXA_RATIO_SET = new Set(["16:9", "9:16", "1:1"]); function normalizeVideoParamsByProvider( source: Partial | undefined, providerType?: string, capability?: VideoToolCapabilityContract | null ): VideoGenerationParams { if (capability) { const modes = capability.modeOptions.map((item) => item.mode); const fallbackMode = capability.defaults.mode || modes[0] || "text-to-video"; const merged = { mode: fallbackMode, resolution: (capability.defaults.resolution || "720p") as VideoGenerationParams["resolution"], ratio: (capability.defaults.ratio || "16:9") as VideoGenerationParams["ratio"], duration: Number(capability.defaults.duration) || 5, generateAudio: capability.defaults.generateAudio ?? false, watermark: false, cameraFixed: false, cameraMove: "none" as const, fps: capability.defaults.fps || 24, ...source, } as VideoGenerationParams; if (!modes.includes(merged.mode)) { merged.mode = fallbackMode as VideoGenerationParams["mode"]; } const scopedOptions = getVideoToolOptionsForMode(capability, merged.mode); const ratios = scopedOptions.ratioOptions; const durations = scopedOptions.durationOptions; const resolutions = scopedOptions.resolutionOptions; const fallbackRatio = scopedOptions.defaults?.ratio || ratios[0] || capability.defaults.ratio || "16:9"; const fallbackDuration = Number(scopedOptions.defaults?.duration) || durations[0] || Number(capability.defaults.duration) || 5; const fallbackResolution = scopedOptions.defaults?.resolution || resolutions[0] || capability.defaults.resolution || "720p"; if (!ratios.includes(merged.ratio)) { merged.ratio = fallbackRatio as VideoGenerationParams["ratio"]; } if (!durations.includes(Number(merged.duration))) { merged.duration = fallbackDuration; } else { merged.duration = Number(merged.duration) || fallbackDuration; } if (resolutions.length > 0 && !resolutions.includes(merged.resolution)) { merged.resolution = fallbackResolution as VideoGenerationParams["resolution"]; } else if (resolutions.length === 0) { merged.resolution = fallbackResolution as VideoGenerationParams["resolution"]; } if (!capability.ui.showGenerateAudio) { merged.generateAudio = false; } else if (typeof merged.generateAudio !== "boolean") { merged.generateAudio = scopedOptions.defaults?.generateAudio ?? capability.defaults.generateAudio ?? false; } if (!capability.ui.showFps) { merged.fps = capability.defaults.fps || 24; } if (!capability.ui.showCameraMove) { merged.cameraMove = "none"; merged.cameraFixed = false; } if (!capability.ui.showReturnLastFrame) { merged.returnLastFrame = false; } if (!capability.ui.showSeed) { merged.seed = undefined; } return merged; } const isFalVideo = providerType === "fal-video"; const isRuxaVideo = providerType === "ruxa-video"; const base: VideoGenerationParams = { mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video", resolution: "720p", ratio: "16:9", duration: 5, generateAudio: true, watermark: false, cameraFixed: false, cameraMove: "none", fps: 24, }; const merged = { ...base, ...source }; if (isFalVideo) { if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") { merged.mode = "first-frame"; } merged.ratio = FAL_RATIO_SET.has(merged.ratio) ? merged.ratio : "16:9"; merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5))); merged.generateAudio = merged.generateAudio ?? true; return merged; } if (isRuxaVideo) { merged.mode = "first-frame"; merged.ratio = RUXA_RATIO_SET.has(merged.ratio) ? merged.ratio : "16:9"; merged.duration = merged.duration >= 8 ? 10 : 5; merged.generateAudio = false; merged.cameraMove = "none"; merged.cameraFixed = false; merged.fps = 24; return merged; } if ( merged.mode !== "text-to-video" && merged.mode !== "first-last-frame" && merged.mode !== "reference-images" && merged.mode !== "first-frame" ) { merged.mode = "text-to-video"; } merged.ratio = STANDARD_RATIO_SET.has(merged.ratio) ? merged.ratio : "16:9"; merged.duration = merged.duration >= 8 ? 10 : 5; return merged; } export function VideoParamsBar({ modelName = "视频 3.0", initialParams, onParamsChange, inline = false, inlineTheme = "default", providerType = "volcano-video", capabilities, }: VideoParamsBarProps) { const isFalVideo = providerType === "fal-video"; const isRuxaVideo = providerType === "ruxa-video"; const capabilityModeOptions = capabilities?.modeOptions?.length ? capabilities.modeOptions.map((item) => ({ value: item.mode, label: item.label || MODE_LABELS[item.mode], })) : null; const modeOptions = capabilityModeOptions || (isFalVideo ? FAL_MODE_OPTIONS : isRuxaVideo ? RUXA_MODE_OPTIONS : STANDARD_MODE_OPTIONS); const showGenerateAudioControl = capabilities ? capabilities.ui.showGenerateAudio : isFalVideo; const showCameraMoveControl = capabilities ? capabilities.ui.showCameraMove : !isFalVideo && !isRuxaVideo; const showFpsControl = capabilities ? capabilities.ui.showFps : !isFalVideo && !isRuxaVideo; const showSeedControl = capabilities ? capabilities.ui.showSeed : true; const showReturnLastFrameControl = capabilities ? capabilities.ui.showReturnLastFrame : true; const [params, setParams] = useState(() => normalizeVideoParamsByProvider(initialParams, providerType, capabilities) ); const scopedCapabilityOptions = getVideoToolOptionsForMode(capabilities, params.mode); const capabilityRatioOptions = scopedCapabilityOptions.ratioOptions.length ? scopedCapabilityOptions.ratioOptions.map((value) => ({ value, label: value })) : null; const ratioOptions = capabilityRatioOptions || (isFalVideo ? FAL_RATIO_OPTIONS : isRuxaVideo ? RUXA_RATIO_OPTIONS : RATIO_OPTIONS); const durationOptions = scopedCapabilityOptions.durationOptions.length ? scopedCapabilityOptions.durationOptions : (isFalVideo ? FAL_DURATION_OPTIONS : isRuxaVideo ? RUXA_DURATION_OPTIONS : STANDARD_DURATION_OPTIONS); const resolutionOptions = scopedCapabilityOptions.resolutionOptions.length ? scopedCapabilityOptions.resolutionOptions.map((value) => ({ value, label: value.toUpperCase(), })) : RESOLUTION_OPTIONS; const showResolutionControl = capabilities ? capabilities.ui.showResolution && scopedCapabilityOptions.resolutionOptions.length > 0 : !isFalVideo && !isRuxaVideo; const [collapsed, setCollapsed] = useState(false); useEffect(() => { const normalized = normalizeVideoParamsByProvider(initialParams, providerType, capabilities); setParams((prev) => { if (JSON.stringify(prev) === JSON.stringify(normalized)) { return prev; } return normalized; }); }, [initialParams, providerType, capabilities]); // 获取当前运镜模式的标签 const getCurrentCameraLabel = () => { if (params.cameraMove) { const option = CAMERA_MOVE_OPTIONS.find(opt => opt.value === params.cameraMove); return option ? option.label : "智能运镜"; } return "智能运镜"; }; // 🆕 根据模式获取图片上传配置(根据火山引擎官方文档) const getImageUploadConfig = () => { if (capabilities?.modeOptions?.length) { const modeOption = capabilities.modeOptions.find((item) => item.mode === params.mode); if (!modeOption) { return { min: 0, max: 0, label: "" }; } const label = modeOption.imageMax === 0 ? `${modeOption.label}(无需图片)` : modeOption.imageMin === modeOption.imageMax ? `${modeOption.label}(${modeOption.imageMax} 张)` : `${modeOption.label}(${modeOption.imageMin}-${modeOption.imageMax} 张)`; return { min: modeOption.imageMin, max: modeOption.imageMax, label, }; } if (isRuxaVideo) { return { min: 1, max: 1, label: "图生视频(1 张起始图)" }; } if (isFalVideo) { switch (params.mode) { case "first-last-frame": return { min: 2, max: 2, label: "首尾帧(2 张)" }; case "first-frame": default: return { min: 1, max: 1, label: "图生视频(1 张起始图)" }; } } switch (params.mode) { case "text-to-video": return { min: 0, max: 0, label: "文生视频(无需图片)" }; case "first-last-frame": return { min: 2, max: 2, label: "首尾帧(各 1 张)" }; case "first-frame": return { min: 1, max: 1, label: "首帧模式(1 张)" }; case "reference-images": return { min: 1, max: 4, label: "参考图(1-4 张)" }; default: return { min: 0, max: 0, label: "" }; } }; const imageConfig = getImageUploadConfig(); const updateParam = ( key: K, value: VideoGenerationParams[K] ) => { const newParams = normalizeVideoParamsByProvider( { ...params, [key]: value }, providerType, capabilities ); setParams(newParams); onParamsChange?.(newParams); }; // 🆕 内联模式:只返回参数按钮,嵌入工具栏(统一玻璃面板风格) if (inline) { const modeLabel = modeOptions.find((o) => o.value === params.mode)?.label || MODE_LABELS[params.mode]; const durationLabel = `${params.duration}s`; const isDarkInline = inlineTheme === "dark"; const panelClass = isDarkInline ? "border-white/10 bg-[#15161a]/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)]" : "border-[#d8dde4] bg-[#f3f4f6]/95 text-[#2f3b48] shadow-[0_10px_24px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100 dark:shadow-[0_18px_44px_rgba(0,0,0,0.38)]"; const groupPanelClass = isDarkInline ? "bg-white/8" : "bg-[#eceff2]/90 dark:bg-white/8"; const labelClass = isDarkInline ? "text-zinc-500" : "text-[#8a94a3] dark:text-zinc-500"; const dividerClass = isDarkInline ? "bg-white/12" : "bg-[#cfd5de] dark:bg-white/12"; const primaryTriggerClass = isDarkInline ? "border-white/10 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]" : "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"; const iconClass = isDarkInline ? "text-zinc-100" : "text-[#20262f] dark:text-zinc-100"; const activeOptionClass = isDarkInline ? "border-white/12 bg-white/10 text-white shadow-none" : "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)] dark:border-white/12 dark:bg-white/10 dark:text-white dark:shadow-none"; const inactiveOptionClass = isDarkInline ? "border-transparent text-zinc-400 hover:bg-white/8 hover:text-zinc-100" : "border-transparent text-[#5f6b7a] hover:bg-white/70 dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100"; return (
{/* 主参数面板:模式 | 比例 | 时长 */}
{/* 模式 */}

选择模式

{modeOptions.map((option) => ( ))}
{/* 比例 */}

选择比例

{ratioOptions.map((option) => ( ))}
{/* 分辨率(非 fal) */} {showResolutionControl && (

选择分辨率

{resolutionOptions.map((option) => ( ))}
)} {/* 时长 */}

选择时长

6 ? "grid-cols-5" : "grid-cols-2" )}> {durationOptions.map((duration) => ( ))}
{/* 副参数:帧率 / 运镜 / 音频(合并为一个 Popover) */}
{showGenerateAudioControl ? (

音频

) : !showFpsControl && !showCameraMoveControl ? (
当前模型支持图生视频(1 张起始图),已自动按官方参数约束处理。
) : ( <> {showFpsControl && (

帧率

{FPS_OPTIONS.map((option) => ( ))}
)} {showCameraMoveControl && (

运镜模式

{CAMERA_MOVE_OPTIONS.map((option) => ( ))}
)} )} {/* 高级选项 */}
{showReturnLastFrameControl && (
返回尾帧 updateParam("returnLastFrame", checked)} />
)} {showGenerateAudioControl && (
生成音频 updateParam("generateAudio", checked)} />
)} {showSeedControl && (
随机种子 updateParam("seed", Number(e.target.value) || undefined)} className={cn( "h-7 rounded-[8px] text-[12px]", isDarkInline ? "border-white/10 bg-white/8 text-zinc-100 placeholder:text-zinc-500" : "border-[#dce1e8] bg-[#eef1f4]/90 text-[#2f3b48] placeholder:text-[#b0b8c4] dark:border-white/10 dark:bg-white/8 dark:text-zinc-100 dark:placeholder:text-zinc-500" )} />
)}
); } // 非内联模式(独立显示) if (collapsed) { return (
视频参数:{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]} | {params.ratio} | {params.duration}s
); } return (
{/* 🆕 图片上传区域(根据模式动态显示) */} {imageConfig.max > 0 && (
{params.mode === "first-last-frame" && (
)} {params.mode === "first-frame" && ( )} {params.mode === "reference-images" && (
{[1, 2, 3, 4].slice(0, imageConfig.max).map((idx) => ( ))}
)}
)} {/* 参数配置栏 - 移除背景色和顶部边框,适应嵌入式布局 */}
{/* 模式选择(无边框,更简洁) */} {modeOptions.map((option) => ( updateParam("mode", option.value)}> {option.label} ))} {/* 宽高比 */}
{ratioOptions.map((option) => ( ))}
{showResolutionControl && ( {resolutionOptions.map((option) => ( updateParam("resolution", option.value)}> {option.label} ))} )} {/* 时长 */} {durationOptions.map((duration) => ( updateParam("duration", duration)}> {duration}s ))} {showGenerateAudioControl ? ( ) : !showFpsControl && !showCameraMoveControl ? ( ) : ( <> {showFpsControl && ( {FPS_OPTIONS.map((option) => ( updateParam("fps", option.value as VideoGenerationParams["fps"])} > {option.label} ))} )} {showCameraMoveControl && ( {CAMERA_MOVE_OPTIONS.map((option) => ( { updateParam("cameraMove", option.value); updateParam("cameraFixed", option.value === "static"); }} > {option.label} ))} )} )}
{/* 高级选项(隐藏水印,默认关闭) */} 高级设置
{showReturnLastFrameControl && (
updateParam("returnLastFrame", checked)} className="scale-75" />
)} {showGenerateAudioControl && (
updateParam("generateAudio", checked)} className="scale-75" />
)} {showSeedControl && (
updateParam("seed", Number(e.target.value) || undefined) } className="h-8 text-xs" />
)}
); }