870 lines
31 KiB
TypeScript
870 lines
31 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import useSWR from "swr";
|
||
import { Loader2, Sparkles } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
ENHANCE_PRO_KREA_MODELS,
|
||
defaultEnhanceProPresets,
|
||
type EnhanceProKreaModel,
|
||
type EnhanceProOutputFormat,
|
||
type EnhanceProPreset,
|
||
type EnhanceProSubjectDetection,
|
||
} from "@/lib/enhance-pro/config";
|
||
import { imageGenerationService, type EnhanceProImageResult } from "@/services/canvas";
|
||
|
||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||
|
||
function createEnhanceProClientRequestId() {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
function shouldPreserveEnhanceProRequestId(message: string) {
|
||
return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test(
|
||
String(message || "")
|
||
);
|
||
}
|
||
|
||
function buildEnhanceProRequestSignature(params: {
|
||
imageUrl: string;
|
||
prompt: string;
|
||
width: number;
|
||
height: number;
|
||
batchSize: number;
|
||
seed: number;
|
||
output_format: EnhanceProOutputFormat;
|
||
subject_detection: EnhanceProSubjectDetection;
|
||
face_enhancement: boolean;
|
||
face_enhancement_creativity: number;
|
||
face_enhancement_strength: number;
|
||
crop_to_fill: boolean;
|
||
model: EnhanceProKreaModel;
|
||
upscaling_activated: boolean;
|
||
image_scaling_factor: number;
|
||
creativity: number;
|
||
texture: number;
|
||
sharpen: number;
|
||
denoise: number;
|
||
detail: number;
|
||
}) {
|
||
return JSON.stringify(params);
|
||
}
|
||
|
||
type EnhanceProClientConfig = {
|
||
enabled: boolean;
|
||
providerName: string;
|
||
baseUrl: string;
|
||
defaultModel: EnhanceProKreaModel;
|
||
availableModels: EnhanceProKreaModel[];
|
||
defaultOutputFormat: EnhanceProOutputFormat;
|
||
allowBatchSize: boolean;
|
||
maxBatchSize: number;
|
||
maxUpscaleFactor: number;
|
||
maxOutputEdge: number;
|
||
enableAdvancedControls: boolean;
|
||
presets: EnhanceProPreset[];
|
||
};
|
||
|
||
type FormState = {
|
||
prompt: string;
|
||
width: number;
|
||
height: number;
|
||
batchSize: number;
|
||
seed: number;
|
||
output_format: EnhanceProOutputFormat;
|
||
subject_detection: EnhanceProSubjectDetection;
|
||
face_enhancement: boolean;
|
||
face_enhancement_creativity: number;
|
||
face_enhancement_strength: number;
|
||
crop_to_fill: boolean;
|
||
model: EnhanceProKreaModel;
|
||
upscaling_activated: boolean;
|
||
image_scaling_factor: number;
|
||
creativity: number;
|
||
texture: number;
|
||
sharpen: number;
|
||
denoise: number;
|
||
detail: number;
|
||
};
|
||
|
||
const FALLBACK_CONFIG: EnhanceProClientConfig = {
|
||
enabled: false,
|
||
providerName: "Krea Topaz Generative",
|
||
baseUrl: "https://api.krea.ai",
|
||
defaultModel: "Redefine",
|
||
availableModels: [...ENHANCE_PRO_KREA_MODELS],
|
||
defaultOutputFormat: "jpg",
|
||
allowBatchSize: true,
|
||
maxBatchSize: 4,
|
||
maxUpscaleFactor: 4,
|
||
maxOutputEdge: 4096,
|
||
enableAdvancedControls: true,
|
||
presets: defaultEnhanceProPresets(),
|
||
};
|
||
|
||
function clampNumber(value: number, min: number, max: number) {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
|
||
const safeWidth = Math.max(1, Math.round(width || 1024));
|
||
const safeHeight = Math.max(1, Math.round(height || 1024));
|
||
const maxEdge = Math.max(512, Math.round(maxOutputEdge || FALLBACK_CONFIG.maxOutputEdge));
|
||
const currentMax = Math.max(safeWidth, safeHeight);
|
||
|
||
if (currentMax <= maxEdge) {
|
||
return { width: safeWidth, height: safeHeight };
|
||
}
|
||
|
||
const scale = maxEdge / currentMax;
|
||
return {
|
||
width: Math.max(1, Math.round(safeWidth * scale)),
|
||
height: Math.max(1, Math.round(safeHeight * scale)),
|
||
};
|
||
}
|
||
|
||
function getAvailableEnhanceProModels(config: EnhanceProClientConfig) {
|
||
return config.availableModels?.length
|
||
? config.availableModels
|
||
: [...ENHANCE_PRO_KREA_MODELS];
|
||
}
|
||
|
||
function resolveEnhanceProModel(
|
||
config: EnhanceProClientConfig,
|
||
model?: EnhanceProKreaModel,
|
||
) {
|
||
const availableModels = getAvailableEnhanceProModels(config);
|
||
if (model && availableModels.includes(model)) {
|
||
return model;
|
||
}
|
||
if (availableModels.includes(config.defaultModel)) {
|
||
return config.defaultModel;
|
||
}
|
||
return availableModels[0] || "Redefine";
|
||
}
|
||
|
||
function createDefaultFormState(config: EnhanceProClientConfig, width: number, height: number): FormState {
|
||
const size = fitDimensions(width, height, config.maxOutputEdge);
|
||
return {
|
||
prompt: "",
|
||
width: size.width,
|
||
height: size.height,
|
||
batchSize: 1,
|
||
seed: 1337,
|
||
output_format: config.defaultOutputFormat,
|
||
subject_detection: "All",
|
||
face_enhancement: false,
|
||
face_enhancement_creativity: 0.5,
|
||
face_enhancement_strength: 0.5,
|
||
crop_to_fill: false,
|
||
model: resolveEnhanceProModel(config, config.defaultModel),
|
||
upscaling_activated: false,
|
||
image_scaling_factor: 1,
|
||
creativity: 3,
|
||
texture: 3,
|
||
sharpen: 0.5,
|
||
denoise: 0.5,
|
||
detail: 0.5,
|
||
};
|
||
}
|
||
|
||
function applyPresetToForm(
|
||
config: EnhanceProClientConfig,
|
||
width: number,
|
||
height: number,
|
||
preset?: EnhanceProPreset,
|
||
): FormState {
|
||
const baseState = createDefaultFormState(config, width, height);
|
||
|
||
if (!preset) {
|
||
return baseState;
|
||
}
|
||
|
||
return {
|
||
...baseState,
|
||
prompt: preset.prompt,
|
||
batchSize: config.allowBatchSize
|
||
? clampNumber(Math.round(preset.batchSize || 1), 1, config.maxBatchSize)
|
||
: 1,
|
||
seed: Number.isFinite(Number(preset.seed)) ? Math.round(Number(preset.seed)) : baseState.seed,
|
||
output_format: preset.outputFormat,
|
||
subject_detection: preset.subjectDetection,
|
||
face_enhancement: preset.faceEnhancement,
|
||
face_enhancement_creativity: clampNumber(preset.faceEnhancementCreativity, 0, 1),
|
||
face_enhancement_strength: clampNumber(preset.faceEnhancementStrength, 0, 1),
|
||
crop_to_fill: preset.cropToFill,
|
||
model: resolveEnhanceProModel(config, preset.model),
|
||
upscaling_activated: preset.upscalingActivated,
|
||
image_scaling_factor: clampNumber(
|
||
preset.imageScalingFactor,
|
||
1,
|
||
config.maxUpscaleFactor
|
||
),
|
||
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
|
||
texture: clampNumber(Math.round(preset.texture), 1, 5),
|
||
sharpen: clampNumber(preset.sharpen, 0, 1),
|
||
denoise: clampNumber(preset.denoise, 0, 1),
|
||
detail: clampNumber(preset.detail, 0, 1),
|
||
};
|
||
}
|
||
|
||
function getSubjectDetectionLabel(subjectDetection: EnhanceProSubjectDetection) {
|
||
if (subjectDetection === "Foreground") {
|
||
return "前景";
|
||
}
|
||
if (subjectDetection === "Background") {
|
||
return "背景";
|
||
}
|
||
return "全部";
|
||
}
|
||
|
||
function buildImageLabel(index: number, total: number) {
|
||
return total > 1 ? `图像增强 Pro #${index + 1}` : "图像增强 Pro";
|
||
}
|
||
|
||
interface EnhanceProDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
imageUrl: string;
|
||
onComplete?: (images: EnhanceProImageResult[]) => void;
|
||
}
|
||
|
||
export function EnhanceProDialog({
|
||
open,
|
||
onOpenChange,
|
||
imageUrl,
|
||
onComplete,
|
||
}: EnhanceProDialogProps) {
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
|
||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||
const requestIdRef = useRef<string | null>(null);
|
||
const requestSignatureRef = useRef<string | null>(null);
|
||
const [formState, setFormState] = useState<FormState>(() =>
|
||
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024),
|
||
);
|
||
|
||
const { data: configPayload, isLoading } = useSWR<{ success?: boolean; config?: EnhanceProClientConfig }>(
|
||
open ? "/api/enhance/pro/config" : null,
|
||
fetcher,
|
||
);
|
||
|
||
const config = useMemo(
|
||
() => configPayload?.config || FALLBACK_CONFIG,
|
||
[configPayload?.config],
|
||
);
|
||
const presets = useMemo(
|
||
() => {
|
||
const availableModels = getAvailableEnhanceProModels(config);
|
||
return (config.presets.length ? config.presets : FALLBACK_CONFIG.presets)
|
||
.filter((preset) => availableModels.includes(preset.model));
|
||
},
|
||
[config],
|
||
);
|
||
const availableModels = useMemo(
|
||
() => getAvailableEnhanceProModels(config),
|
||
[config],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!open || !imageUrl) return;
|
||
|
||
const image = new window.Image();
|
||
image.onload = () => {
|
||
setNaturalSize({
|
||
width: image.naturalWidth || 1024,
|
||
height: image.naturalHeight || 1024,
|
||
});
|
||
};
|
||
image.onerror = () => {
|
||
setNaturalSize({ width: 1024, height: 1024 });
|
||
};
|
||
image.src = imageUrl;
|
||
}, [open, imageUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const defaultPreset = presets.find((preset) => preset.isDefault) || presets[0];
|
||
setSelectedPresetId(defaultPreset?.id || "");
|
||
setFormState(applyPresetToForm(config, naturalSize.width, naturalSize.height, defaultPreset));
|
||
}, [config, naturalSize.height, naturalSize.width, open, presets]);
|
||
|
||
const handleNumericField = <K extends keyof FormState>(key: K, nextValue: number) => {
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
[key]: nextValue,
|
||
}));
|
||
};
|
||
|
||
const handleOpenChange = (nextOpen: boolean) => {
|
||
if (isSubmitting) return;
|
||
onOpenChange(nextOpen);
|
||
};
|
||
|
||
const handleSelectPreset = (presetId: string) => {
|
||
const preset = presets.find((item) => item.id === presetId);
|
||
setSelectedPresetId(presetId);
|
||
setFormState(applyPresetToForm(config, naturalSize.width, naturalSize.height, preset));
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!config.enabled) {
|
||
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
const requestParams = {
|
||
imageUrl,
|
||
prompt: formState.prompt.trim() || "图像增强 Pro",
|
||
width: clampNumber(Math.round(formState.width), 1, config.maxOutputEdge),
|
||
height: clampNumber(Math.round(formState.height), 1, config.maxOutputEdge),
|
||
batchSize: config.allowBatchSize
|
||
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
|
||
: 1,
|
||
seed: Math.round(formState.seed),
|
||
output_format: formState.output_format,
|
||
subject_detection: formState.subject_detection,
|
||
face_enhancement: formState.face_enhancement,
|
||
face_enhancement_creativity: clampNumber(formState.face_enhancement_creativity, 0, 1),
|
||
face_enhancement_strength: clampNumber(formState.face_enhancement_strength, 0, 1),
|
||
crop_to_fill: formState.crop_to_fill,
|
||
model: resolveEnhanceProModel(config, formState.model),
|
||
upscaling_activated: formState.upscaling_activated,
|
||
image_scaling_factor: clampNumber(
|
||
formState.image_scaling_factor,
|
||
1,
|
||
config.maxUpscaleFactor
|
||
),
|
||
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
|
||
texture: clampNumber(Math.round(formState.texture), 1, 5),
|
||
sharpen: clampNumber(formState.sharpen, 0, 1),
|
||
denoise: clampNumber(formState.denoise, 0, 1),
|
||
detail: clampNumber(formState.detail, 0, 1),
|
||
};
|
||
const requestSignature = buildEnhanceProRequestSignature(requestParams);
|
||
const clientRequestId =
|
||
requestIdRef.current && requestSignatureRef.current === requestSignature
|
||
? requestIdRef.current
|
||
: createEnhanceProClientRequestId();
|
||
requestIdRef.current = clientRequestId;
|
||
requestSignatureRef.current = requestSignature;
|
||
try {
|
||
const result = await imageGenerationService.enhanceImagePro({
|
||
...requestParams,
|
||
clientRequestId,
|
||
});
|
||
|
||
if (!result.success || !result.data?.images?.length) {
|
||
throw new Error(result.error || "图像增强 Pro 失败");
|
||
}
|
||
|
||
onComplete?.(result.data.images);
|
||
requestIdRef.current = null;
|
||
requestSignatureRef.current = null;
|
||
toast.success(`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`);
|
||
onOpenChange(false);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "图像增强 Pro 失败";
|
||
if (!shouldPreserveEnhanceProRequestId(message)) {
|
||
requestIdRef.current = null;
|
||
requestSignatureRef.current = null;
|
||
}
|
||
toast.error(message);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||
<DialogContent
|
||
overlayClassName="bg-black"
|
||
className="sm:max-w-[840px] border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-950"
|
||
onInteractOutside={(event) => {
|
||
if (isSubmitting) event.preventDefault();
|
||
}}
|
||
onEscapeKeyDown={(event) => {
|
||
if (isSubmitting) event.preventDefault();
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Sparkles className="h-5 w-5" />
|
||
图像增强 Pro
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
使用 {config.providerName} 对当前图片执行更可控的增强与重建。处理完成后会把结果直接添加到无限画布。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="max-h-[70vh] space-y-6 overflow-y-auto pr-1">
|
||
{isLoading ? (
|
||
<div className="flex items-center gap-2 rounded-lg border p-4 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在加载图像增强 Pro 配置...
|
||
</div>
|
||
) : null}
|
||
|
||
{!config.enabled && !isLoading ? (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||
当前未启用图像增强 Pro。请前往系统设置的“本地AI设置”页签完成 Krea 配置后再使用。
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-lg border bg-slate-50/60 p-4 text-sm text-muted-foreground dark:bg-slate-900/40">
|
||
原图尺寸:{naturalSize.width} x {naturalSize.height}
|
||
<span className="mx-2">|</span>
|
||
当前最大输出长边:{config.maxOutputEdge}px
|
||
</div>
|
||
|
||
{presets.length ? (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="text-base">增强预设</Label>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
先选一个更贴近场景的预设,再按图片情况继续微调参数。
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
{presets.map((preset) => {
|
||
const isSelected = preset.id === selectedPresetId;
|
||
return (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
className={`rounded-xl border p-4 text-left transition ${
|
||
isSelected
|
||
? "border-sky-500 bg-sky-50 shadow-sm"
|
||
: "border-slate-200 bg-white hover:border-slate-300"
|
||
}`}
|
||
onClick={() => handleSelectPreset(preset.id)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<div className="font-medium text-slate-900">{preset.name}</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{preset.description || "使用这一组默认参数快速开始增强。"}
|
||
</p>
|
||
</div>
|
||
{preset.isDefault ? (
|
||
<span className="rounded-full bg-slate-900 px-2 py-0.5 text-[11px] text-white">
|
||
默认
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-3 text-[11px] text-slate-600">
|
||
{preset.model} · {getSubjectDetectionLabel(preset.subjectDetection)}
|
||
{preset.faceEnhancement ? " · 人像增强" : ""}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-prompt">增强提示词</Label>
|
||
<Textarea
|
||
id="enhance-pro-prompt"
|
||
rows={3}
|
||
value={formState.prompt}
|
||
onChange={(event) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
prompt: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="例如:保持原图风格不变,提升细节层次,减少压缩噪点,让人物皮肤更自然。"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-width">输出宽度</Label>
|
||
<Input
|
||
id="enhance-pro-width"
|
||
type="number"
|
||
min="1"
|
||
max={config.maxOutputEdge}
|
||
value={formState.width}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"width",
|
||
clampNumber(Number.parseInt(event.target.value, 10) || 1, 1, config.maxOutputEdge),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-height">输出高度</Label>
|
||
<Input
|
||
id="enhance-pro-height"
|
||
type="number"
|
||
min="1"
|
||
max={config.maxOutputEdge}
|
||
value={formState.height}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"height",
|
||
clampNumber(Number.parseInt(event.target.value, 10) || 1, 1, config.maxOutputEdge),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label>增强模式</Label>
|
||
<Select
|
||
value={formState.model}
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
model: value as FormState["model"],
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableModels.map((model) => (
|
||
<SelectItem key={model} value={model}>
|
||
{model}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>输出格式</Label>
|
||
<Select
|
||
value={formState.output_format}
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
output_format: value as FormState["output_format"],
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="jpg">JPG</SelectItem>
|
||
<SelectItem value="png">PNG</SelectItem>
|
||
<SelectItem value="webp">WEBP</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>作用区域</Label>
|
||
<Select
|
||
value={formState.subject_detection}
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
subject_detection: value as FormState["subject_detection"],
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="All">全部</SelectItem>
|
||
<SelectItem value="Foreground">前景</SelectItem>
|
||
<SelectItem value="Background">背景</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{config.allowBatchSize ? (
|
||
<div className="space-y-2">
|
||
<Label>候选结果数</Label>
|
||
<Select
|
||
value={String(formState.batchSize)}
|
||
onValueChange={(value) =>
|
||
handleNumericField(
|
||
"batchSize",
|
||
clampNumber(Number.parseInt(value, 10) || 1, 1, config.maxBatchSize),
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Array.from({ length: config.maxBatchSize }, (_, index) => index + 1).map((item) => (
|
||
<SelectItem key={item} value={String(item)}>
|
||
{item} 张
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-seed">Seed</Label>
|
||
<Input
|
||
id="enhance-pro-seed"
|
||
type="number"
|
||
value={formState.seed}
|
||
onChange={(event) =>
|
||
handleNumericField("seed", Number.parseInt(event.target.value, 10) || 1337)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-image-scaling">放大倍数</Label>
|
||
<Input
|
||
id="enhance-pro-image-scaling"
|
||
type="number"
|
||
min="1"
|
||
max={config.maxUpscaleFactor}
|
||
step="0.25"
|
||
value={formState.image_scaling_factor}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"image_scaling_factor",
|
||
clampNumber(
|
||
Number.parseFloat(event.target.value) || 1,
|
||
1,
|
||
config.maxUpscaleFactor
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="space-y-0.5 pr-4">
|
||
<Label className="text-sm">人脸增强</Label>
|
||
<div className="text-xs text-muted-foreground">适合人像、半身图和证件风修复。</div>
|
||
</div>
|
||
<Switch
|
||
checked={formState.face_enhancement}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({ ...prev, face_enhancement: checked }))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="space-y-0.5 pr-4">
|
||
<Label className="text-sm">启用放大</Label>
|
||
<div className="text-xs text-muted-foreground">允许模型在增强时同时执行更强的上采样。</div>
|
||
</div>
|
||
<Switch
|
||
checked={formState.upscaling_activated}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({ ...prev, upscaling_activated: checked }))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="space-y-0.5 pr-4">
|
||
<Label className="text-sm">裁剪铺满</Label>
|
||
<div className="text-xs text-muted-foreground">输出尺寸与原图比例不同时,裁切并铺满目标画布。</div>
|
||
</div>
|
||
<Switch
|
||
checked={formState.crop_to_fill}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{config.enableAdvancedControls ? (
|
||
<>
|
||
<Separator />
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="text-base">高级调节</Label>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
这些参数会直接映射到 Krea Topaz Generative,对结果风格与细节影响更明显。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-creativity">Creativity (1-6)</Label>
|
||
<Input
|
||
id="enhance-pro-creativity"
|
||
type="number"
|
||
min="1"
|
||
max="6"
|
||
value={formState.creativity}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"creativity",
|
||
clampNumber(Number.parseInt(event.target.value, 10) || 3, 1, 6),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-texture">Texture (1-5)</Label>
|
||
<Input
|
||
id="enhance-pro-texture"
|
||
type="number"
|
||
min="1"
|
||
max="5"
|
||
value={formState.texture}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"texture",
|
||
clampNumber(Number.parseInt(event.target.value, 10) || 3, 1, 5),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-detail">Detail (0-1)</Label>
|
||
<Input
|
||
id="enhance-pro-detail"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={formState.detail}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"detail",
|
||
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-sharpen">Sharpen (0-1)</Label>
|
||
<Input
|
||
id="enhance-pro-sharpen"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={formState.sharpen}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"sharpen",
|
||
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-denoise">Denoise (0-1)</Label>
|
||
<Input
|
||
id="enhance-pro-denoise"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={formState.denoise}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"denoise",
|
||
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-face-strength">Face Strength (0-1)</Label>
|
||
<Input
|
||
id="enhance-pro-face-strength"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={formState.face_enhancement_strength}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"face_enhancement_strength",
|
||
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="enhance-pro-face-creativity">Face Creativity (0-1)</Label>
|
||
<Input
|
||
id="enhance-pro-face-creativity"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={formState.face_enhancement_creativity}
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"face_enhancement_creativity",
|
||
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900">
|
||
<p>处理完成后,结果会保存到相册和存储桶,并作为新图片节点添加到当前画布。</p>
|
||
{config.allowBatchSize ? (
|
||
<p className="mt-1">
|
||
当前设置允许返回多张候选结果。画布中会按 {buildImageLabel(0, formState.batchSize)}
|
||
的方式依次创建新节点。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSubmit} disabled={isSubmitting || isLoading || !config.enabled}>
|
||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||
开始增强 Pro
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export default EnhanceProDialog;
|