ai-chatbot/components/video-params-bar.tsx

1060 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<VideoGenerationParams>;
onParamsChange?: (params: VideoGenerationParams) => void;
inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器
inlineTheme?: "default" | "dark";
providerType?: string;
capabilities?: VideoToolCapabilityContract | null;
}
const MODE_LABELS: Record<VideoGenerationMode, string> = {
"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<VideoGenerationParams> | 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<VideoGenerationParams>(() =>
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 = <K extends keyof VideoGenerationParams>(
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 (
<div
className={cn(
"ml-1 flex w-max shrink-0 items-center gap-1 border-l pl-1",
isDarkInline ? "border-white/10" : "border-border/50"
)}
>
{/* 主参数面板:模式 | 比例 | 时长 */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-8 shrink-0 justify-start rounded-[12px] border px-2 text-[11px] font-semibold",
primaryTriggerClass
)}
title={`${modeLabel},${params.ratio},${durationLabel}`}
>
<Clapperboard className={cn("mr-1 h-3.5 w-3.5 shrink-0", iconClass)} />
<span className="whitespace-nowrap">{modeLabel}</span>
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
<span className="shrink-0">{params.ratio}</span>
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
<span className="shrink-0">{durationLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className={cn(
"w-[288px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border p-2",
panelClass
)}
>
<div className="space-y-2.5">
{/* 模式 */}
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>选择模式</p>
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
{modeOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.mode === option.value ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("mode", option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{/* 比例 */}
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>选择比例</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
{ratioOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.ratio === option.value ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("ratio", option.value as VideoGenerationParams["ratio"])}
>
{option.label}
</button>
))}
</div>
</div>
{/* 分辨率(非 fal) */}
{showResolutionControl && (
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>选择分辨率</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1", groupPanelClass)}>
{resolutionOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"h-8 rounded-[9px] text-[12px] font-semibold transition",
params.resolution === option.value
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("resolution", option.value)}
>
{option.label}
</button>
))}
</div>
</div>
)}
{/* 时长 */}
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>选择时长</p>
<div className={cn(
"mt-1.5 grid gap-1.5 rounded-[var(--ui-radius-control)] p-1.5",
groupPanelClass,
durationOptions.length > 6 ? "grid-cols-5" : "grid-cols-2"
)}>
{durationOptions.map((duration) => (
<button
key={duration}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.duration === duration ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("duration", duration)}
>
{duration}s
</button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* 副参数:帧率 / 运镜 / 音频(合并为一个 Popover) */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-8 w-8 shrink-0 rounded-[12px] border p-0",
isDarkInline
? "border-white/10 bg-[#242529] text-zinc-300 hover:bg-[#2c2d33] hover:text-zinc-100"
: "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#5f6b7a] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-300 dark:hover:bg-[#2c2d33] dark:hover:text-zinc-100"
)}
title="更多参数"
>
<SlidersHorizontal size={14} />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
sideOffset={10}
className={cn(
"w-[280px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border p-2",
panelClass
)}
>
<div className="space-y-2.5">
{showGenerateAudioControl ? (
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>音频</p>
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
<button
type="button"
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
(params.generateAudio ?? true)
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("generateAudio", true)}
>
<Volume2 size={13} />
带音频
</button>
<button
type="button"
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
params.generateAudio === false
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("generateAudio", false)}
>
<VolumeX size={13} />
静音
</button>
</div>
</div>
) : !showFpsControl && !showCameraMoveControl ? (
<div
className={cn(
"rounded-[var(--ui-radius-control)] p-2 text-[11px]",
isDarkInline
? "bg-white/8 text-zinc-400"
: "bg-[#eceff2]/90 text-[#7b8796] dark:bg-white/8 dark:text-zinc-400"
)}
>
当前模型支持图生视频(1 张起始图),已自动按官方参数约束处理。
</div>
) : (
<>
{showFpsControl && (
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>帧率</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
{FPS_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
(params.fps || 24) === option.value
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
>
{option.label}
</button>
))}
</div>
</div>
)}
{showCameraMoveControl && (
<div>
<p className={cn("text-[11px] font-medium", labelClass)}>运镜模式</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
{CAMERA_MOVE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[11px] font-semibold transition",
params.cameraMove === option.value
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => {
updateParam("cameraMove", option.value);
updateParam("cameraFixed", option.value === "static");
}}
>
{option.label}
</button>
))}
</div>
</div>
)}
</>
)}
{/* 高级选项 */}
<div
className={cn(
"space-y-2.5 border-t pt-2.5",
isDarkInline ? "border-white/10" : "border-[#dce1e8] dark:border-white/10"
)}
>
{showReturnLastFrameControl && (
<div className="flex items-center justify-between">
<span className={cn("text-[11px] font-medium", labelClass)}>返回尾帧</span>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
/>
</div>
)}
{showGenerateAudioControl && (
<div className="flex items-center justify-between">
<span className={cn("text-[11px] font-medium", labelClass)}>生成音频</span>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
/>
</div>
)}
{showSeedControl && (
<div className="space-y-1">
<span className={cn("text-[11px] font-medium", labelClass)}>随机种子</span>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ""}
onChange={(e) => 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"
)}
/>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
// 非内联模式(独立显示)
if (collapsed) {
return (
<div className="flex items-center justify-between py-2 px-4 border-t bg-muted/20">
<span className="text-sm text-muted-foreground">
视频参数:{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]} | {params.ratio} | {params.duration}s
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCollapsed(false)}
>
<ChevronDown size={16} />
</Button>
</div>
);
}
return (
<div className="bg-background/90 backdrop-blur-sm border-t-0 p-2">
{/* 🆕 图片上传区域(根据模式动态显示) */}
{imageConfig.max > 0 && (
<div className="pb-2 flex justify-center">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground mr-2">{imageConfig.label}</Label>
{params.mode === "first-last-frame" && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5 h-14 w-20 flex-col border-dashed hover:border-solid hover:bg-accent/50"
>
<span className="text-xl">+</span>
<span className="text-[10px]">首帧</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-14 w-20 flex-col border-dashed hover:border-solid hover:bg-accent/50"
>
<span className="text-xl">➡</span>
<span className="text-[10px]">尾帧</span>
</Button>
</div>
)}
{params.mode === "first-frame" && (
<Button
variant="outline"
size="sm"
className="gap-1.5 h-14 w-20 flex-col border-dashed hover:border-solid hover:bg-accent/50"
>
<span className="text-xl">+</span>
<span className="text-[10px]">图帧</span>
</Button>
)}
{params.mode === "reference-images" && (
<div className="flex gap-2">
{[1, 2, 3, 4].slice(0, imageConfig.max).map((idx) => (
<Button
key={idx}
variant="outline"
size="sm"
className="gap-1.5 h-14 w-14 flex-col border-dashed hover:border-solid hover:bg-accent/50"
>
<span className="text-xl">+</span>
<span className="text-[10px]">{idx}</span>
</Button>
))}
</div>
)}
</div>
</div>
)}
{/* 参数配置栏 - 移除背景色和顶部边框,适应嵌入式布局 */}
<div className="flex items-center justify-between gap-2 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-2">
{/* 模式选择(无边框,更简洁) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
<Clapperboard size={14} />
{modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}
<ChevronDown size={12} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{modeOptions.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => updateParam("mode", option.value)}>
<Check className={cn("mr-2 h-4 w-4", params.mode === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-4" />
{/* 宽高比 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
<Ratio size={14} />
{params.ratio}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64">
<div className="p-2 grid grid-cols-3 gap-1">
{ratioOptions.map((option) => (
<Button
key={option.value}
variant={params.ratio === option.value ? "secondary" : "ghost"}
size="sm"
className="h-7 text-xs"
onClick={() => updateParam("ratio", option.value as VideoGenerationParams["ratio"])}
>
{option.label}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{showResolutionControl && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
{params.resolution}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{resolutionOptions.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => updateParam("resolution", option.value)}>
<Check className={cn("mr-2 h-4 w-4", params.resolution === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 时长 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
<Clock size={14} />
{params.duration}s
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[80px]">
{durationOptions.map((duration) => (
<DropdownMenuItem key={duration} onClick={() => updateParam("duration", duration)}>
<Check className={cn("mr-2 h-4 w-4", params.duration === duration ? "opacity-100" : "opacity-0")} />
{duration}s
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{showGenerateAudioControl ? (
<Button
variant="ghost"
size="sm"
className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() => updateParam("generateAudio", !(params.generateAudio ?? true))}
>
<Sparkles size={14} />
{params.generateAudio === false ? "静音" : "带音频"}
</Button>
) : !showFpsControl && !showCameraMoveControl ? (
<Button
variant="ghost"
size="sm"
className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0"
disabled
>
<Sparkles size={14} />
图生视频
</Button>
) : (
<>
{showFpsControl && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
{params.fps || 24}fps
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{FPS_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
>
<Check
className={cn(
"mr-2 h-4 w-4",
(params.fps || 24) === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{showCameraMoveControl && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1 px-2 h-8 focus-visible:ring-0 focus-visible:ring-offset-0">
<Camera size={14} />
{getCurrentCameraLabel()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[140px]">
{CAMERA_MOVE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
updateParam("cameraMove", option.value);
updateParam("cameraFixed", option.value === "static");
}}
>
<Check
className={cn("mr-2 h-4 w-4", params.cameraMove === option.value ? "opacity-100" : "opacity-0")}
/>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</div>
{/* 高级选项(隐藏水印,默认关闭) */}
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground/70 focus-visible:ring-0 focus-visible:ring-offset-0">
<Settings2 size={14} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[300px]">
<DialogHeader>
<DialogTitle className="text-sm">高级设置</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{showReturnLastFrameControl && (
<div className="flex items-center justify-between">
<Label className="text-xs">返回尾帧</Label>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
className="scale-75"
/>
</div>
)}
{showGenerateAudioControl && (
<div className="flex items-center justify-between">
<Label className="text-xs">生成音频</Label>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
className="scale-75"
/>
</div>
)}
{showSeedControl && (
<div className="space-y-1.5">
<Label className="text-xs">随机种子</Label>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ""}
onChange={(e) =>
updateParam("seed", Number(e.target.value) || undefined)
}
className="h-8 text-xs"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}