1125 lines
39 KiB
TypeScript
1125 lines
39 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2,
|
||
ImageIcon,
|
||
Loader2,
|
||
Send,
|
||
Sparkles,
|
||
} 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 { 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(),
|
||
};
|
||
|
||
const PANEL_SHELL_CLASS =
|
||
"pointer-events-auto rounded-[28px] border border-[#e7e1d4] bg-white/88 shadow-[0_20px_50px_rgba(15,23,42,0.08)] backdrop-blur-xl";
|
||
const SOLID_PANEL_FIELD_CLASS =
|
||
"border-[#e7e1d4] bg-[#fffdfa] text-foreground shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
|
||
const SOLID_PANEL_CARD_CLASS =
|
||
"rounded-[22px] border border-[#e7e1d4] bg-[#fffdfa] shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
|
||
const PANEL_SECTION_TITLE_CLASS = "font-semibold text-[13px] text-slate-700";
|
||
const PANEL_SECTION_NOTE_CLASS = "text-[11px] leading-5 text-slate-500";
|
||
const SOLID_PANEL_SELECT_CONTENT_CLASS =
|
||
"border-[#e7e1d4] bg-[#fffdfa] shadow-[0_16px_34px_rgba(15,23,42,0.08)]";
|
||
const PRIMARY_ACTION_BUTTON_CLASS =
|
||
"border border-[#2563eb] bg-[#2563eb] text-white shadow-[0_10px_22px_rgba(37,99,235,0.18)] transition hover:border-[#1d4ed8] hover:bg-[#1d4ed8] disabled:border-[#e7e1d4] disabled:bg-slate-200 disabled:text-slate-500 disabled:shadow-none";
|
||
const SECONDARY_ACTION_BUTTON_CLASS =
|
||
"border border-[#d8e4ff] bg-[#eef4ff] text-[#1d4ed8] shadow-[0_8px_20px_rgba(59,130,246,0.10)] transition hover:bg-[#e4eeff] hover:text-[#1e40af] disabled:border-[#e7e1d4] disabled:bg-slate-100 disabled:text-slate-400 disabled:shadow-none";
|
||
|
||
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)
|
||
: "原图";
|
||
const actionHint = selectedGeneratedImage
|
||
? `已选中 ${activePreviewLabel}`
|
||
: generatedImages.length > 0
|
||
? `已生成 ${generatedImages.length} 张候选结果`
|
||
: "生成后可直接发送回画布";
|
||
|
||
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-background text-foreground">
|
||
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-muted/20">
|
||
{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-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-1.5 text-muted-foreground text-xs shadow-sm backdrop-blur-sm">
|
||
<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-muted-foreground/35" />
|
||
<div className="font-semibold text-foreground text-lg">
|
||
还没有可增强的图片
|
||
</div>
|
||
<div className="text-muted-foreground text-sm leading-6">
|
||
请先在主画布中选中一张图片,再从底部工具栏进入“图像增强 Pro”,
|
||
或者先在实时画布模式里生成一张结果后再切回来。
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sidePreviewItems.length > 0 ? (
|
||
<div className="absolute right-6 bottom-28 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-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-3 shadow-xl backdrop-blur-sm">
|
||
{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-500 shadow-[0_0_0_1px_rgba(14,165,233,0.18)]"
|
||
: "border-[#e7e1d4] hover:shadow-sm"
|
||
)}
|
||
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="pointer-events-none absolute top-20 right-6 bottom-6 z-20 w-[420px] max-w-[42vw]">
|
||
<div className="h-full space-y-4 overflow-y-auto pr-1 pb-6">
|
||
{isLoading ? (
|
||
<div
|
||
className={cn(
|
||
PANEL_SHELL_CLASS,
|
||
"flex items-center gap-2 px-4 py-3 text-muted-foreground text-sm"
|
||
)}
|
||
>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在加载增强配置...
|
||
</div>
|
||
) : null}
|
||
|
||
{!config.enabled && !isLoading ? (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-amber-800 text-sm leading-6">
|
||
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
|
||
配置。
|
||
</div>
|
||
) : null}
|
||
|
||
{lastErrorMessage ? (
|
||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-rose-700 text-sm leading-6">
|
||
{lastErrorMessage}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className={cn(PANEL_SHELL_CLASS, "space-y-4 px-4 py-4")}>
|
||
<div className="space-y-2">
|
||
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
|
||
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
|
||
<SelectTrigger
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
>
|
||
<SelectValue placeholder="选择一个增强预设" />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
{presets.map((preset) => (
|
||
<SelectItem key={preset.id} value={preset.id}>
|
||
{preset.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{selectedPreset ? (
|
||
<div className={cn(PANEL_SECTION_NOTE_CLASS, "line-clamp-2")}>
|
||
{selectedPreset.description ||
|
||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label
|
||
className={PANEL_SECTION_TITLE_CLASS}
|
||
htmlFor="enhance-workspace-prompt"
|
||
>
|
||
增强提示词
|
||
</Label>
|
||
<Textarea
|
||
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[92px] resize-none rounded-[22px] placeholder:text-muted-foreground/70`}
|
||
id="enhance-workspace-prompt"
|
||
onChange={(event) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
prompt: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
|
||
rows={3}
|
||
value={formState.prompt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>基础参数</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className="text-slate-600 text-xs"
|
||
htmlFor="enhance-workspace-width"
|
||
>
|
||
宽度
|
||
</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label
|
||
className="text-slate-600 text-xs"
|
||
htmlFor="enhance-workspace-height"
|
||
>
|
||
高度
|
||
</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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>
|
||
{config.allowBatchSize ? (
|
||
<div className="space-y-1.5">
|
||
<Label className="text-slate-600 text-xs">候选</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
handleNumericField(
|
||
"batchSize",
|
||
clampNumber(
|
||
Number.parseInt(value, 10) || 1,
|
||
1,
|
||
config.maxBatchSize
|
||
)
|
||
)
|
||
}
|
||
value={String(formState.batchSize)}
|
||
>
|
||
<SelectTrigger
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
{Array.from(
|
||
{ length: config.maxBatchSize },
|
||
(_, index) => index + 1
|
||
).map((item) => (
|
||
<SelectItem key={item} value={String(item)}>
|
||
{item} 张
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className="text-slate-600 text-xs"
|
||
htmlFor="enhance-workspace-seed"
|
||
>
|
||
Seed
|
||
</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">模式</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
model: value as FormState["model"],
|
||
}))
|
||
}
|
||
value={formState.model}
|
||
>
|
||
<SelectTrigger
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
<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-1.5">
|
||
<Label className="text-slate-600 text-xs">格式</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
output_format: value as FormState["output_format"],
|
||
}))
|
||
}
|
||
value={formState.output_format}
|
||
>
|
||
<SelectTrigger
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
<SelectItem value="jpg">JPG</SelectItem>
|
||
<SelectItem value="png">PNG</SelectItem>
|
||
<SelectItem value="webp">WEBP</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-slate-600 text-xs">区域</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
subject_detection: value as FormState["subject_detection"],
|
||
}))
|
||
}
|
||
value={formState.subject_detection}
|
||
>
|
||
<SelectTrigger
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
<SelectItem value="All">全部</SelectItem>
|
||
<SelectItem value="Foreground">前景</SelectItem>
|
||
<SelectItem value="Background">背景</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{config.allowBatchSize ? (
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className="text-slate-600 text-xs"
|
||
htmlFor="enhance-workspace-seed"
|
||
>
|
||
Seed
|
||
</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
id="enhance-workspace-seed"
|
||
onChange={(event) =>
|
||
handleNumericField(
|
||
"seed",
|
||
Number.parseInt(event.target.value, 10) || 1337
|
||
)
|
||
}
|
||
type="number"
|
||
value={formState.seed}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className="text-slate-600 text-xs"
|
||
htmlFor="enhance-workspace-scale"
|
||
>
|
||
放大
|
||
</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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>
|
||
|
||
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>增强开关</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div
|
||
className={cn(
|
||
SOLID_PANEL_CARD_CLASS,
|
||
"flex items-center justify-between gap-3 px-3 py-3"
|
||
)}
|
||
>
|
||
<span className="text-foreground text-sm">人脸增强</span>
|
||
<Switch
|
||
checked={formState.face_enhancement}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
face_enhancement: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
SOLID_PANEL_CARD_CLASS,
|
||
"flex items-center justify-between gap-3 px-3 py-3"
|
||
)}
|
||
>
|
||
<span className="text-foreground text-sm">启用放大</span>
|
||
<Switch
|
||
checked={formState.upscaling_activated}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
upscaling_activated: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
SOLID_PANEL_CARD_CLASS,
|
||
"flex items-center justify-between gap-3 px-3 py-3"
|
||
)}
|
||
>
|
||
<span className="text-foreground text-sm">裁剪铺满</span>
|
||
<Switch
|
||
checked={formState.crop_to_fill}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{config.enableAdvancedControls ? (
|
||
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>高级调节</div>
|
||
<div className={PANEL_SECTION_NOTE_CLASS}>细修参数</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-slate-600 text-xs">创造力</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">纹理</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">细节</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">锐化</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">降噪</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">脸部力度</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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-1.5">
|
||
<Label className="text-slate-600 text-xs">脸部创意</Label>
|
||
<Input
|
||
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
|
||
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>
|
||
|
||
<div className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center gap-2">
|
||
<div className="pointer-events-auto rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-4 py-1.5 text-muted-foreground text-xs shadow-lg backdrop-blur-xl">
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedGeneratedImage ? (
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||
) : null}
|
||
{actionHint}
|
||
</span>
|
||
</div>
|
||
<div className="pointer-events-auto inline-flex items-center justify-center gap-3 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 p-2 shadow-2xl backdrop-blur-xl">
|
||
<Button
|
||
className={cn(
|
||
"h-11 min-w-[220px] rounded-full px-6",
|
||
PRIMARY_ACTION_BUTTON_CLASS
|
||
)}
|
||
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" />
|
||
)}
|
||
开始增强
|
||
</Button>
|
||
<Button
|
||
className={cn(
|
||
"h-11 min-w-[220px] rounded-full px-6",
|
||
SECONDARY_ACTION_BUTTON_CLASS
|
||
)}
|
||
disabled={!selectedGeneratedImage}
|
||
onClick={handleApply}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Send className="mr-2 h-4 w-4" />
|
||
发送到画布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default EnhanceProWorkspace;
|