ai-chatbot/components/video-params-bar.tsx
Your Name a1691fbd39
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(canvas): add deep-thinking toggle for image mode and polish video mode icon
2026-02-20 20:13:30 +08:00

757 lines
28 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 {
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 { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Video,
Check,
ChevronDown,
ChevronUp,
Sparkles,
Camera,
Clock,
Settings2,
Clapperboard,
Monitor,
Ratio,
} from "lucide-react";
import { cn } from "@/lib/utils";
// 视频生成模式类型
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; // 内联模式:只显示参数按钮,不显示外层容器
providerType?: string;
}
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 FAL_RATIO_OPTIONS = [
{ value: "16:9", label: "16:9" },
{ value: "9:16", label: "9:16" },
{ value: "1:1", label: "1:1" },
] as const;
const STANDARD_DURATION_OPTIONS = [5, 10];
const FAL_DURATION_OPTIONS = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
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"]);
function normalizeVideoParamsByProvider(
source: Partial<VideoGenerationParams> | undefined,
isFalVideo: boolean
): VideoGenerationParams {
const base: VideoGenerationParams = {
mode: isFalVideo ? "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 (
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,
providerType = "volcano-video",
}: VideoParamsBarProps) {
const isFalVideo = providerType === "fal-video";
const modeOptions = isFalVideo ? FAL_MODE_OPTIONS : STANDARD_MODE_OPTIONS;
const ratioOptions = isFalVideo ? FAL_RATIO_OPTIONS : RATIO_OPTIONS;
const durationOptions = isFalVideo ? FAL_DURATION_OPTIONS : STANDARD_DURATION_OPTIONS;
const [params, setParams] = useState<VideoGenerationParams>(() =>
normalizeVideoParamsByProvider(initialParams, isFalVideo)
);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const normalized = normalizeVideoParamsByProvider(initialParams, isFalVideo);
setParams((prev) => {
if (JSON.stringify(prev) === JSON.stringify(normalized)) {
return prev;
}
return normalized;
});
}, [initialParams, isFalVideo]);
// 获取当前运镜模式的标签
const getCurrentCameraLabel = () => {
if (params.cameraMove) {
const option = CAMERA_MOVE_OPTIONS.find(opt => opt.value === params.cameraMove);
return option ? option.label : "智能运镜";
}
return "智能运镜";
};
// 🆕 根据模式获取图片上传配置(根据火山引擎官方文档)
const getImageUploadConfig = () => {
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 = { ...params, [key]: value };
setParams(newParams);
onParamsChange?.(newParams);
};
// 🆕 内联模式:只返回参数按钮,嵌入工具栏
if (inline) {
return (
<div className="flex items-center ml-1 border-l border-border/50 pl-1">
{/* 模式选择 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`模式:${modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}`}
>
<Clapperboard size={14} />
<span className="sr-only">
{`模式:${modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}`}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
{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>
{/* 宽高比 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`比例:${params.ratio}`}
>
<Ratio size={14} />
<span className="sr-only">{`比例:${params.ratio}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<div className="p-1.5 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)}
>
{option.label}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{!isFalVideo && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`分辨率:${params.resolution}`}
>
<Monitor size={14} />
<span className="sr-only">{`分辨率:${params.resolution}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{RESOLUTION_OPTIONS.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"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`时长:${params.duration}s`}
>
<Clock size={14} />
<span className="sr-only">{`时长:${params.duration}s`}</span>
</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>
{isFalVideo ? (
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() => updateParam("generateAudio", !(params.generateAudio ?? true))}
title={`音频:${params.generateAudio === false ? "静音" : "带音频"}`}
>
<Sparkles size={14} />
<span className="sr-only">{`音频:${params.generateAudio === false ? "静音" : "带音频"}`}</span>
</Button>
) : (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`帧率:${params.fps || 24}fps`}
>
<Video size={14} />
<span className="sr-only">{`帧率:${params.fps || 24}fps`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[90px]">
{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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`运镜:${getCurrentCameraLabel()}`}
>
<Camera size={14} />
<span className="sr-only">{`运镜:${getCurrentCameraLabel()}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{CAMERA_MOVE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => {
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>
</>
)}
{/* 高级设置 */}
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" className="h-8 w-8 px-0 focus-visible:ring-0 focus-visible:ring-offset-0">
<Settings2 size={14} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[280px]">
<DialogHeader>
<DialogTitle className="text-sm">高级设置</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex items-center justify-between">
<Label className="text-xs">返回尾帧</Label>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
/>
</div>
{isFalVideo && (
<div className="flex items-center justify-between">
<Label className="text-xs">生成音频</Label>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
/>
</div>
)}
<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>
);
}
// 非内联模式(独立显示)
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/95 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)}
>
{option.label}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{!isFalVideo && (
<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]">
{RESOLUTION_OPTIONS.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>
{isFalVideo ? (
<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>
) : (
<>
<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>
<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">
<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>
{isFalVideo && (
<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>
)}
<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>
);
}