ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name b3d9b70ed4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: move enhance pro into realtime canvas workspace
2026-04-05 21:12:24 +08:00

1108 lines
37 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 {
CheckCircle2,
ImageIcon,
Loader2,
Send,
Sparkles,
Wand2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
defaultEnhanceProPresets,
type EnhanceProKreaModel,
type EnhanceProOutputFormat,
type EnhanceProPreset,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import {
type EnhanceProImageResult,
type EnhanceProRequestParams,
imageGenerationService,
} from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: 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",
defaultOutputFormat: "jpg",
allowBatchSize: true,
maxBatchSize: 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 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: 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: preset.model,
upscaling_activated: preset.upscalingActivated,
image_scaling_factor: clampNumber(preset.imageScalingFactor, 1, 32),
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 getDisplayImageUrl(url?: string | null, width = 1400) {
const raw = String(url || "").trim();
if (!raw) return "";
return /^https?:\/\//i.test(raw)
? buildImageProxyUrl(raw, {
width,
quality: 88,
format: "webp",
})
: raw;
}
function getSubjectDetectionLabel(
subjectDetection: EnhanceProSubjectDetection
) {
if (subjectDetection === "Foreground") return "前景";
if (subjectDetection === "Background") return "背景";
return "全部";
}
function getActiveResultLabel(index: number) {
return `结果 ${index + 1}`;
}
function buildEnhanceSourcePayload(
sourceImageUrl?: string | null
): Pick<
EnhanceProRequestParams,
"imageUrl" | "image_base64" | "mimeType"
> | null {
const raw = String(sourceImageUrl || "").trim();
if (!raw) return null;
const dataUrlMatch = raw.match(/^data:([^;]+);base64,(.+)$/);
if (dataUrlMatch) {
return {
image_base64: dataUrlMatch[2],
mimeType: dataUrlMatch[1] || "image/png",
};
}
if (/^https?:\/\//i.test(raw)) {
return {
imageUrl: raw,
};
}
return null;
}
interface EnhanceProWorkspaceProps {
sourceImageUrl?: string | null;
onApplyResult: (payload: {
imageUrl: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => void;
}
export function EnhanceProWorkspace({
sourceImageUrl,
onApplyResult,
}: EnhanceProWorkspaceProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
const [selectedPresetId, setSelectedPresetId] = useState("");
const [selectedResultIndex, setSelectedResultIndex] = useState<number>(-1);
const [formState, setFormState] = useState<FormState>(() =>
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024)
);
const [generatedImages, setGeneratedImages] = useState<
EnhanceProImageResult[]
>([]);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const { data: configPayload, isLoading } = useSWR<{
success?: boolean;
config?: EnhanceProClientConfig;
}>("/api/enhance/pro/config", fetcher);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config]
);
const presets = useMemo(
() => (config.presets.length ? config.presets : FALLBACK_CONFIG.presets),
[config.presets]
);
const selectedPreset = useMemo(
() => presets.find((item) => item.id === selectedPresetId) || null,
[presets, selectedPresetId]
);
const selectedGeneratedImage =
selectedResultIndex >= 0
? generatedImages[selectedResultIndex] || null
: null;
const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex)
: "原图";
useEffect(() => {
const imageSource = String(sourceImageUrl || "").trim();
if (!imageSource) {
setNaturalSize({ width: 1024, height: 1024 });
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 = imageSource;
}, [sourceImageUrl]);
useEffect(() => {
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, presets]);
useEffect(() => {
setGeneratedImages([]);
setSelectedResultIndex(-1);
setLastErrorMessage("");
}, [sourceImageUrl]);
const handleNumericField = <K extends keyof FormState>(
key: K,
nextValue: number
) => {
setFormState((prev) => ({
...prev,
[key]: nextValue,
}));
};
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;
}
const sourcePayload = buildEnhanceSourcePayload(sourceImageUrl);
if (!sourcePayload) {
toast.error(
"当前没有可用于增强的图片,请先选择画布图片或切回实时模式准备一张结果图"
);
return;
}
setIsSubmitting(true);
setLastErrorMessage("");
try {
const result = await imageGenerationService.enhanceImagePro({
...sourcePayload,
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: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
32
),
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),
});
if (!result.success || !result.data?.images?.length) {
throw new Error(result.error || "图像增强 Pro 失败");
}
setGeneratedImages(result.data.images);
setSelectedResultIndex(0);
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
);
} catch (error) {
const message =
error instanceof Error ? error.message : "图像增强 Pro 失败";
setLastErrorMessage(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleApply = () => {
if (!selectedGeneratedImage?.originalUrl) {
toast.error("请先生成结果并选择一张增强图");
return;
}
onApplyResult({
imageUrl: selectedGeneratedImage.originalUrl,
prompt: formState.prompt.trim() || "图像增强 Pro",
title: "图像增强 Pro 结果",
model: selectedPreset?.model || config.defaultModel,
width: selectedGeneratedImage.width,
height: selectedGeneratedImage.height,
});
};
const sidePreviewItems = useMemo(() => {
const items: Array<{
key: string;
label: string;
url: string;
isSource?: boolean;
}> = [];
if (sourceImageUrl) {
items.push({
key: "source",
label: "原图",
url: sourceImageUrl,
isSource: true,
});
}
generatedImages.forEach((image, index) => {
items.push({
key: `result-${index}`,
label: getActiveResultLabel(index),
url: image.thumbnailUrl || image.originalUrl,
});
});
return items;
}, [generatedImages, sourceImageUrl]);
return (
<div className="relative flex h-full w-full bg-[#09090b] text-white">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-[#050506]">
{activePreviewUrl ? (
<>
<img
alt={activePreviewLabel}
className="max-h-full max-w-full object-contain"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
/>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-white/80 text-xs backdrop-blur-md">
<ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span>
</div>
</>
) : (
<div className="flex max-w-md flex-col items-center gap-3 px-6 text-center">
<Sparkles className="h-10 w-10 text-white/25" />
<div className="font-semibold text-lg text-white">
还没有可增强的图片
</div>
<div className="text-sm text-white/55 leading-6">
请先在主画布中选中一张图片,再从底部工具栏进入“图像增强 Pro”,
或者先在实时画布模式里生成一张结果后再切回来。
</div>
</div>
)}
{sidePreviewItems.length > 0 ? (
<div className="absolute right-6 bottom-6 left-6 flex items-center justify-center">
<div className="flex max-w-[min(720px,calc(100vw-5rem))] items-center gap-2 rounded-[24px] border border-white/10 bg-black/45 px-3 py-3 shadow-2xl backdrop-blur-xl">
{sidePreviewItems.map((item, index) => {
const isActive = item.isSource
? selectedResultIndex < 0
: selectedResultIndex === index - 1;
return (
<button
className={cn(
"group flex h-16 w-16 shrink-0 overflow-hidden rounded-2xl border transition",
isActive
? "border-sky-400 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]"
: "border-white/10 hover:border-white/35"
)}
key={item.key}
onClick={() =>
setSelectedResultIndex(item.isSource ? -1 : index - 1)
}
title={item.label}
type="button"
>
<img
alt={item.label}
className="h-full w-full object-cover"
src={getDisplayImageUrl(item.url, 220)}
/>
</button>
);
})}
</div>
</div>
) : null}
</div>
<div className="flex h-full w-[360px] shrink-0 flex-col border-white/10 border-l bg-[#111217]/95">
<div className="border-white/10 border-b px-5 py-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-base text-white">
<Wand2 className="h-4.5 w-4.5 text-sky-300" />
图像增强模式
</div>
<div className="mt-1 text-white/55 text-xs leading-5">
使用 {config.providerName}{" "}
对单张图片进行更可控的增强、修复和细节重建。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">
{selectedPreset?.model || config.defaultModel}
</div>
</div>
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10 bg-black/30">
{sourceImageUrl ? (
<img
alt="增强源图"
className="h-[128px] w-full object-cover"
src={getDisplayImageUrl(sourceImageUrl, 420)}
/>
) : (
<div className="flex h-[128px] items-center justify-center text-sm text-white/40">
暂无源图
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-white/50">
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
原图 {naturalSize.width} x {naturalSize.height}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
最大输出长边 {config.maxOutputEdge}px
</span>
</div>
</div>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5">
{isLoading ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载增强配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-amber-100 text-sm leading-6">
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
配置。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-rose-100 text-sm leading-6">
{lastErrorMessage}
</div>
) : null}
<div className="space-y-2">
<Label className="text-white/80">增强预设</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger className="border-white/10 bg-white/5 text-white">
<SelectValue placeholder="选择一个增强预设" />
</SelectTrigger>
<SelectContent>
{presets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPreset ? (
<div className="text-white/45 text-xs leading-5">
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
) : null}
</div>
<div className="space-y-2">
<Label className="text-white/80" htmlFor="enhance-workspace-prompt">
增强提示词
</Label>
<Textarea
className="border-white/10 bg-white/5 text-white placeholder:text-white/30"
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={4}
value={formState.prompt}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label
className="text-white/80"
htmlFor="enhance-workspace-width"
>
输出宽度
</Label>
<Input
className="border-white/10 bg-white/5 text-white"
id="enhance-workspace-width"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"width",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.width}
/>
</div>
<div className="space-y-2">
<Label
className="text-white/80"
htmlFor="enhance-workspace-height"
>
输出高度
</Label>
<Input
className="border-white/10 bg-white/5 text-white"
id="enhance-workspace-height"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"height",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.height}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label className="text-white/80">增强模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
value={formState.model}
>
<SelectTrigger className="border-white/10 bg-white/5 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Redefine">Redefine</SelectItem>
<SelectItem value="Recovery">Recovery</SelectItem>
<SelectItem value="Recovery V2">Recovery V2</SelectItem>
<SelectItem value="Reimagine">Reimagine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-white/80">输出格式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
output_format: value as FormState["output_format"],
}))
}
value={formState.output_format}
>
<SelectTrigger className="border-white/10 bg-white/5 text-white">
<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 className="text-white/80">作用区域</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
subject_detection: value as FormState["subject_detection"],
}))
}
value={formState.subject_detection}
>
<SelectTrigger className="border-white/10 bg-white/5 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">全部</SelectItem>
<SelectItem value="Foreground">前景</SelectItem>
<SelectItem value="Background">背景</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{config.allowBatchSize ? (
<div className="space-y-2">
<Label className="text-white/80">候选结果</Label>
<Select
onValueChange={(value) =>
handleNumericField(
"batchSize",
clampNumber(
Number.parseInt(value, 10) || 1,
1,
config.maxBatchSize
)
)
}
value={String(formState.batchSize)}
>
<SelectTrigger className="border-white/10 bg-white/5 text-white">
<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 className="text-white/80" htmlFor="enhance-workspace-seed">
Seed
</Label>
<Input
className="border-white/10 bg-white/5 text-white"
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
"seed",
Number.parseInt(event.target.value, 10) || 1337
)
}
type="number"
value={formState.seed}
/>
</div>
<div className="space-y-2">
<Label
className="text-white/80"
htmlFor="enhance-workspace-scale"
>
放大倍数
</Label>
<Input
className="border-white/10 bg-white/5 text-white"
id="enhance-workspace-scale"
max="32"
min="1"
onChange={(event) =>
handleNumericField(
"image_scaling_factor",
clampNumber(
Number.parseFloat(event.target.value) || 1,
1,
32
)
)
}
step="0.5"
type="number"
value={formState.image_scaling_factor}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm text-white">人脸增强</div>
<div className="mt-1 text-[11px] text-white/45 leading-4">
适合人像和模特图修复
</div>
</div>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
face_enhancement: checked,
}))
}
/>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm text-white">启用放大</div>
<div className="mt-1 text-[11px] text-white/45 leading-4">
强化增强时的上采样
</div>
</div>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
upscaling_activated: checked,
}))
}
/>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm text-white">裁剪铺满</div>
<div className="mt-1 text-[11px] text-white/45 leading-4">
尺寸比例不同时裁切填充
</div>
</div>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
}
/>
</div>
</div>
</div>
{config.enableAdvancedControls ? (
<>
<Separator className="bg-white/10" />
<div className="space-y-3">
<div className="font-medium text-sm text-white">高级调节</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-white/80">Creativity (1-6)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="6"
min="1"
onChange={(event) =>
handleNumericField(
"creativity",
clampNumber(
Number.parseInt(event.target.value, 10) || 3,
1,
6
)
)
}
type="number"
value={formState.creativity}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">Texture (1-5)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="5"
min="1"
onChange={(event) =>
handleNumericField(
"texture",
clampNumber(
Number.parseInt(event.target.value, 10) || 3,
1,
5
)
)
}
type="number"
value={formState.texture}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">Detail (0-1)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"detail",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.detail}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">Sharpen (0-1)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"sharpen",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.sharpen}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">Denoise (0-1)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"denoise",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.denoise}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">Face Strength (0-1)</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"face_enhancement_strength",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.face_enhancement_strength}
/>
</div>
<div className="space-y-2">
<Label className="text-white/80">
Face Creativity (0-1)
</Label>
<Input
className="border-white/10 bg-white/5 text-white"
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"face_enhancement_creativity",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.face_enhancement_creativity}
/>
</div>
</div>
</div>
</>
) : null}
</div>
<div className="border-white/10 border-t px-5 py-4">
<div className="mb-3 flex items-center justify-between text-white/45 text-xs">
<span>
{generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "结果会在生成后显示并可发送回画布"}
</span>
{selectedGeneratedImage ? (
<span className="inline-flex items-center gap-1 text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
已选中 {activePreviewLabel}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
className="h-11 flex-1 rounded-2xl bg-[#0f62fe] text-white hover:bg-[#0a58e0]"
disabled={isSubmitting || !sourceImageUrl}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
开始增强 Pro
</Button>
<Button
className="h-11 rounded-2xl border-white/10 bg-white/5 px-4 text-white hover:bg-white/10 hover:text-white"
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;