feat: add per-model 1K/2K/4K resolution toggles & unify toolbar UI style
- Add supports1K/supports2K/supports4K fields to imageGenConfig for per-model resolution control in admin settings - Unify video-params-bar inline mode to match image-params-bar glass panel popover style (rounded corners, consistent colors, grid layout) - Unify image-params-bar count selector and advanced settings to same glass panel style - Update imageGenConfig types across all consuming components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
77c9ac244b
commit
afaab632ed
@ -134,6 +134,9 @@ type AiProvider = {
|
||||
type ImageGenConfig = {
|
||||
maxImages?: number; // 最大输出图片数,如阿里 1-6,Gemini 1-2
|
||||
supportsSize?: boolean; // 是否支持自定义分辨率
|
||||
supports1K?: boolean; // 是否支持 1K 分辨率
|
||||
supports2K?: boolean; // 是否支持 2K 分辨率
|
||||
supports4K?: boolean; // 是否支持 4K 分辨率
|
||||
supportsPromptExtend?: boolean; // 是否支持提示词智能优化
|
||||
supportsNegativePrompt?: boolean; // 是否支持反向提示词
|
||||
supportsWatermark?: boolean; // 是否支持水印开关
|
||||
@ -1117,6 +1120,9 @@ export function AdminSettingsClient() {
|
||||
imageGenConfig: {
|
||||
maxImages: 2,
|
||||
supportsSize: false,
|
||||
supports1K: true,
|
||||
supports2K: true,
|
||||
supports4K: true,
|
||||
supportsPromptExtend: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
@ -6466,7 +6472,7 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
<Label className="text-sm">支持自定义分辨率</Label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={modelForm.imageGenConfig?.enableLoadingPlaceholder !== false}
|
||||
@ -6481,6 +6487,53 @@ export function AdminSettingsClient() {
|
||||
<Label className="text-sm">启用加载占位图</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分辨率档位开关 */}
|
||||
<div className="border rounded-md p-3 space-y-2 bg-muted/20">
|
||||
<div className="text-sm font-medium text-muted-foreground">分辨率档位控制</div>
|
||||
<p className="text-xs text-muted-foreground">控制前台用户可选的分辨率档位,关闭后用户将无法选择对应分辨率</p>
|
||||
<div className="grid grid-cols-3 gap-4 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={modelForm.imageGenConfig?.supports1K !== false}
|
||||
onCheckedChange={(checked) => setModelForm({
|
||||
...modelForm,
|
||||
imageGenConfig: {
|
||||
...modelForm.imageGenConfig,
|
||||
supports1K: checked,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Label className="text-sm">1K</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={modelForm.imageGenConfig?.supports2K !== false}
|
||||
onCheckedChange={(checked) => setModelForm({
|
||||
...modelForm,
|
||||
imageGenConfig: {
|
||||
...modelForm.imageGenConfig,
|
||||
supports2K: checked,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Label className="text-sm">2K</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={modelForm.imageGenConfig?.supports4K !== false}
|
||||
onCheckedChange={(checked) => setModelForm({
|
||||
...modelForm,
|
||||
imageGenConfig: {
|
||||
...modelForm.imageGenConfig,
|
||||
supports4K: checked,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Label className="text-sm">4K</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -6545,6 +6598,9 @@ export function AdminSettingsClient() {
|
||||
imageGenConfig: (model as any).imageGenConfig || {
|
||||
maxImages: 2,
|
||||
supportsSize: false,
|
||||
supports1K: true,
|
||||
supports2K: true,
|
||||
supports4K: true,
|
||||
supportsPromptExtend: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
|
||||
@ -20,6 +20,9 @@ import {
|
||||
const imageGenConfigSchema = z.object({
|
||||
maxImages: z.number().min(1).max(10).optional(),
|
||||
supportsSize: z.boolean().optional(),
|
||||
supports1K: z.boolean().optional(),
|
||||
supports2K: z.boolean().optional(),
|
||||
supports4K: z.boolean().optional(),
|
||||
supportsPromptExtend: z.boolean().optional(),
|
||||
supportsNegativePrompt: z.boolean().optional(),
|
||||
supportsWatermark: z.boolean().optional(),
|
||||
|
||||
@ -2005,6 +2005,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
@ -2020,6 +2023,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
|
||||
@ -378,6 +378,9 @@ export function Chat({
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
|
||||
@ -99,6 +99,9 @@ type ModelOption = {
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
@ -408,6 +411,9 @@ export function LovartHome({
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
|
||||
@ -2,13 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@ -17,7 +11,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// 图片生成参数接口
|
||||
export interface ImageGenerationParams {
|
||||
@ -44,6 +37,9 @@ interface ImageParamsBarProps {
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
@ -114,9 +110,22 @@ export function ImageParamsBar({
|
||||
const options = getImageCountOptions();
|
||||
const fallbackCount = options[0] ?? 1;
|
||||
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
||||
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
|
||||
let imageSize = raw.imageSize || "2K";
|
||||
if (imageGenConfig) {
|
||||
const sizeAllowed = (s: string) => {
|
||||
if (s === "1K") return imageGenConfig.supports1K !== false;
|
||||
if (s === "2K") return imageGenConfig.supports2K !== false;
|
||||
if (s === "4K") return imageGenConfig.supports4K !== false;
|
||||
return true;
|
||||
};
|
||||
if (!sizeAllowed(imageSize)) {
|
||||
imageSize = ["2K", "1K", "4K"].find(sizeAllowed) || "2K";
|
||||
}
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
imageSize: raw.imageSize || "2K",
|
||||
imageSize,
|
||||
imageCount,
|
||||
};
|
||||
};
|
||||
@ -153,6 +162,16 @@ export function ImageParamsBar({
|
||||
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
|
||||
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
|
||||
|
||||
// 根据模型配置过滤可用的分辨率档位
|
||||
const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => {
|
||||
if (option.disabled) return false;
|
||||
if (!imageGenConfig) return true; // 无配置时全部显示
|
||||
if (option.value === "1K" && imageGenConfig.supports1K === false) return false;
|
||||
if (option.value === "2K" && imageGenConfig.supports2K === false) return false;
|
||||
if (option.value === "4K" && imageGenConfig.supports4K === false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
|
||||
const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"];
|
||||
if (aspectRatio === "auto" || aspectRatio === "1:1") {
|
||||
@ -221,7 +240,7 @@ export function ImageParamsBar({
|
||||
if (initialParams !== undefined && onParamsChange && !isSameParams(rawParams, nextParams)) {
|
||||
onParamsChange(nextParams);
|
||||
}
|
||||
}, [initialParams, providerType, imageGenConfig?.maxImages]);
|
||||
}, [initialParams, providerType, imageGenConfig?.maxImages, imageGenConfig?.supports1K, imageGenConfig?.supports2K, imageGenConfig?.supports4K]);
|
||||
|
||||
const updateParam = <K extends keyof ImageGenerationParams>(
|
||||
key: K,
|
||||
@ -295,7 +314,7 @@ export function ImageParamsBar({
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
|
||||
{IMAGE_SIZE_OPTIONS.filter((option) => !option.disabled).map((option) => (
|
||||
{availableSizeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
@ -339,41 +358,58 @@ export function ImageParamsBar({
|
||||
</Popover>
|
||||
|
||||
{showImageCountSelector && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 min-w-[56px] rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
className="h-8 min-w-[56px] rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
title={`数量:${params.imageCount || 1}张`}
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5 text-[#6b7280]" />
|
||||
{params.imageCount || 1}张
|
||||
<span className="sr-only">{`数量:${params.imageCount || 1}张`}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[80px]">
|
||||
{imageCountOptions.map((count) => (
|
||||
<DropdownMenuItem
|
||||
key={count}
|
||||
onClick={() => updateParam("imageCount", count)}
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", (params.imageCount || 1) === count ? "opacity-100" : "opacity-0")} />
|
||||
{count}张
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-auto min-w-[120px] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">生成数量</p>
|
||||
<div className={cn(
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
|
||||
imageCountOptions.length <= 3 ? "grid-cols-3" : "grid-cols-3"
|
||||
)}>
|
||||
{imageCountOptions.map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 min-w-[36px] items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
(params.imageCount || 1) === count
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("imageCount", count)}
|
||||
>
|
||||
{count}张
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* 🆕 阿里生图专用:提示词优化 & 反向提示词 */}
|
||||
{!hideAdvancedOptions && (showPromptExtend || showNegativePrompt) && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
"h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
(params.promptExtend || params.negativePrompt) && "text-primary"
|
||||
)}
|
||||
title="高级设置"
|
||||
@ -382,16 +418,19 @@ export function ImageParamsBar({
|
||||
<span className="sr-only">高级设置</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="space-y-4">
|
||||
<div className="font-medium text-sm">高级选项</div>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{/* 提示词智能优化 */}
|
||||
{showPromptExtend && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">提示词优化</Label>
|
||||
<p className="text-xs text-muted-foreground">AI 自动优化您的描述</p>
|
||||
<span className="text-[12px] font-semibold text-[#2f3b48]">提示词优化</span>
|
||||
<p className="text-[11px] text-[#8a94a3]">AI 自动优化您的描述</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={params.promptExtend ?? true}
|
||||
@ -399,16 +438,16 @@ export function ImageParamsBar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 反向提示词 */}
|
||||
{showNegativePrompt && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">反向提示词</Label>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[12px] font-semibold text-[#2f3b48]">反向提示词</span>
|
||||
<Input
|
||||
placeholder="不希望出现的内容,如:低质量、模糊..."
|
||||
value={params.negativePrompt || ""}
|
||||
onChange={(e) => updateParam("negativePrompt", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -21,6 +21,9 @@ type DynamicModel = {
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
@ -153,6 +156,9 @@ function PureMultimodalInput({
|
||||
imageGenConfig?: {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
|
||||
@ -8,6 +8,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -18,7 +23,6 @@ import {
|
||||
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,
|
||||
@ -32,6 +36,9 @@ import {
|
||||
Clapperboard,
|
||||
Monitor,
|
||||
Ratio,
|
||||
SlidersHorizontal,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -265,219 +272,266 @@ const getImageUploadConfig = () => {
|
||||
onParamsChange?.(newParams);
|
||||
};
|
||||
|
||||
// 🆕 内联模式:只返回参数按钮,嵌入工具栏
|
||||
// 🆕 内联模式:只返回参数按钮,嵌入工具栏(统一玻璃面板风格)
|
||||
if (inline) {
|
||||
const modeLabel = modeOptions.find((o) => o.value === params.mode)?.label || MODE_LABELS[params.mode];
|
||||
const durationLabel = `${params.duration}s`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center ml-1 border-l border-border/50 pl-1">
|
||||
{/* 模式选择 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
|
||||
{/* 主参数面板:模式 | 比例 | 时长 */}
|
||||
<Popover>
|
||||
<PopoverTrigger 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]}`}
|
||||
className="h-8 max-w-[200px] shrink rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2 text-[11px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
|
||||
title={`${modeLabel},${params.ratio},${durationLabel}`}
|
||||
>
|
||||
<Clapperboard size={14} />
|
||||
<span className="sr-only">
|
||||
{`模式:${modeOptions.find((option) => option.value === params.mode)?.label || MODE_LABELS[params.mode]}`}
|
||||
</span>
|
||||
<Clapperboard className="mr-1 h-3.5 w-3.5 text-[#20262f]" />
|
||||
<span className="truncate">{modeLabel}</span>
|
||||
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
|
||||
<span className="truncate">{params.ratio}</span>
|
||||
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
|
||||
<span className="truncate">{durationLabel}</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 ? "静音" : "带音频"}`}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
>
|
||||
<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 className="space-y-2.5">
|
||||
{/* 模式 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择模式</p>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
{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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("mode", option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择比例</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
{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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("ratio", option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分辨率(非 fal) */}
|
||||
{!isFalVideo && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
|
||||
{RESOLUTION_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-8 rounded-[9px] text-[12px] font-semibold transition",
|
||||
params.resolution === option.value
|
||||
? "bg-white text-[#111827] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("resolution", option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">选择时长</p>
|
||||
<div className={cn(
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
|
||||
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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("duration", duration)}
|
||||
>
|
||||
{duration}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 副参数:帧率 / 运镜 / 音频(合并为一个 Popover) */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90"
|
||||
title="更多参数"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{isFalVideo ? (
|
||||
/* fal-video:音频开关 */
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">音频</p>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
<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)
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("generateAudio", false)}
|
||||
>
|
||||
<VolumeX size={13} />
|
||||
静音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 帧率 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">帧率</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
{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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 运镜 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#8a94a3]">运镜模式</p>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
|
||||
{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
|
||||
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
|
||||
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
|
||||
)}
|
||||
onClick={() => {
|
||||
updateParam("cameraMove", option.value);
|
||||
updateParam("cameraFixed", option.value === "static");
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div className="space-y-2.5 border-t border-[#dce1e8] pt-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">返回尾帧</span>
|
||||
<Switch
|
||||
checked={params.returnLastFrame || false}
|
||||
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
|
||||
/>
|
||||
</div>
|
||||
{isFalVideo && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">生成音频</span>
|
||||
<Switch
|
||||
checked={params.generateAudio ?? true}
|
||||
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-[#8a94a3]">随机种子</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="留空则随机"
|
||||
value={params.seed || ""}
|
||||
onChange={(e) => updateParam("seed", Number(e.target.value) || undefined)}
|
||||
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1414,6 +1414,9 @@ export async function getDefaultAiModel(): Promise<AiModel | undefined> {
|
||||
interface ImageGenConfig {
|
||||
maxImages?: number;
|
||||
supportsSize?: boolean;
|
||||
supports1K?: boolean;
|
||||
supports2K?: boolean;
|
||||
supports4K?: boolean;
|
||||
supportsPromptExtend?: boolean;
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user