ai-chatbot/components/video-params-bar.tsx
Your Name 53ddb7e585
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Initial commit - clean repository
2025-12-17 20:15:18 +08:00

613 lines
24 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 { 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,
Maximize2,
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;
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; // 内联模式:只显示参数按钮,不显示外层容器
}
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;
export function VideoParamsBar({
modelName = "视频 3.0",
initialParams,
onParamsChange,
inline = false,
}: VideoParamsBarProps) {
const [params, setParams] = useState<VideoGenerationParams>({
mode: "text-to-video",
resolution: "720p",
ratio: "16:9",
duration: 5,
watermark: false, // 默认关闭水印
cameraFixed: false,
cameraMove: "none",
fps: 24,
...initialParams,
});
const [collapsed, setCollapsed] = useState(false);
// 获取当前运镜模式的标签
const getCurrentCameraLabel = () => {
if (params.cameraMove) {
const option = CAMERA_MOVE_OPTIONS.find(opt => opt.value === params.cameraMove);
return option ? option.label : "智能运镜";
}
return "智能运镜";
};
// 🆕 根据模式获取图片上传配置(根据火山引擎官方文档)
const getImageUploadConfig = () => {
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 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
<Maximize2 size={14} />
<span className="font-medium text-xs">{MODE_LABELS[params.mode]}</span>
<ChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
<DropdownMenuItem onClick={() => updateParam("mode", "text-to-video")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "text-to-video" ? "opacity-100" : "opacity-0")} />
文生视频
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "first-last-frame")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-last-frame" ? "opacity-100" : "opacity-0")} />
首尾帧
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "reference-images")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "reference-images" ? "opacity-100" : "opacity-0")} />
智能多帧
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "first-frame")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-frame" ? "opacity-100" : "opacity-0")} />
主体参考
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 宽高比 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
<Ratio size={14} />
<span className="font-medium text-xs">{params.ratio}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<div className="p-1.5 grid grid-cols-3 gap-1">
{RATIO_OPTIONS.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>
{/* 分辨率 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
<span className="font-medium text-xs">{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 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
<Clock size={14} />
<span className="font-medium text-xs">{params.duration}s</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[80px]">
{[5, 10].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>
{/* 帧率 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0">
<span className="font-medium text-xs">{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 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0">
<Camera size={14} />
<span className="font-medium text-xs">{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>
<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">
视频参数:{MODE_LABELS[params.mode]} | {params.ratio} | {params.resolution} | {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">
<Maximize2 size={14} />
{MODE_LABELS[params.mode]}
<ChevronDown size={12} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuItem onClick={() => updateParam("mode", "text-to-video")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "text-to-video" ? "opacity-100" : "opacity-0")} />
文生视频
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "first-last-frame")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-last-frame" ? "opacity-100" : "opacity-0")} />
首尾帧
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "first-frame")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "first-frame" ? "opacity-100" : "opacity-0")} />
智能多帧
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam("mode", "reference-images")}>
<Check className={cn("mr-2 h-4 w-4", params.mode === "reference-images" ? "opacity-100" : "opacity-0")} />
主体参考
</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">
{RATIO_OPTIONS.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>
{/* 分辨率 */}
<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]">
{[5, 10].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>
{/* 帧率 (新增) */}
<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>
<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>
);
}