1397 lines
51 KiB
TypeScript
1397 lines
51 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ImageIcon,
|
||
Loader2,
|
||
ScanFace,
|
||
Send,
|
||
Settings2,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import {
|
||
type PointerEvent as ReactPointerEvent,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
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 { Slider } from "@/components/ui/slider";
|
||
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 {
|
||
calculateEnhanceProPointsEstimate,
|
||
defaultPointsSystemConfig,
|
||
type EnhanceProPointsPricingConfig,
|
||
} from "@/lib/points/config";
|
||
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 EnhanceProClientPointsConfig = {
|
||
enabled: boolean;
|
||
blockOnInsufficient: boolean;
|
||
enhanceProPricing: EnhanceProPointsPricingConfig;
|
||
};
|
||
|
||
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 FALLBACK_POINTS_CONFIG: EnhanceProClientPointsConfig = {
|
||
enabled: false,
|
||
blockOnInsufficient: true,
|
||
enhanceProPricing: defaultPointsSystemConfig.enhanceProPricing,
|
||
};
|
||
|
||
const PANEL_SHELL_CLASS =
|
||
"pointer-events-auto rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_20px_40px_rgba(0,0,0,0.06)]";
|
||
const SOLID_PANEL_FIELD_CLASS =
|
||
"border-[#e5e5e5] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.04)]";
|
||
const SOLID_PANEL_CARD_CLASS =
|
||
"rounded-[22px] border border-[#ececec] bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_22px_rgba(0,0,0,0.04)]";
|
||
const PANEL_SECTION_TITLE_CLASS =
|
||
"font-semibold text-[15px] tracking-[-0.04em] text-[#111111]";
|
||
const PANEL_SECTION_NOTE_CLASS =
|
||
"text-[12px] leading-5 text-[#666666]";
|
||
const SOLID_PANEL_SELECT_CONTENT_CLASS =
|
||
"border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.08)]";
|
||
const PRIMARY_ACTION_BUTTON_CLASS =
|
||
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
|
||
const SECONDARY_ACTION_BUTTON_CLASS =
|
||
"border border-[#d9d9d9] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_22px_rgba(0,0,0,0.03)] transition hover:border-[#111111] hover:bg-[#fafafa] hover:text-black disabled:border-[#e5e5e5] disabled:bg-[#f7f7f7] disabled:text-[#a5a5a5] disabled:shadow-none";
|
||
const COMPACT_FIELD_CLASS = `${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[16px] text-sm`;
|
||
const COMPACT_LABEL_CLASS =
|
||
"font-medium text-[#666666] text-[11px] leading-4 tracking-[0.01em]";
|
||
const COMPACT_TOGGLE_CARD_CLASS =
|
||
"flex items-center justify-between gap-2.5 px-3.5 py-3";
|
||
const SLIDER_TRACK_CLASS = "h-2.5 bg-[#ededed] shadow-inner";
|
||
const SLIDER_RANGE_CLASS = "bg-[#111111]";
|
||
const SLIDER_THUMB_CLASS =
|
||
"h-4 w-4 border border-[#d4d4d4] bg-white shadow-[0_0_0_4px_rgba(0,0,0,0.08)]";
|
||
const SIDEBAR_SCROLL_CLASS =
|
||
"pr-1.5 [scrollbar-width:thin] [scrollbar-color:#c8c8c8_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#c8c8c8] hover:[&::-webkit-scrollbar-thumb]:bg-[#a8a8a8]";
|
||
const SIDEBAR_SECTION_CARD_CLASS =
|
||
"rounded-[28px] border border-[#e5e5e5] bg-white p-3.5 shadow-[0_18px_36px_rgba(0,0,0,0.05)]";
|
||
const SECTION_EYEBROW_CLASS =
|
||
"inline-flex items-center gap-1.5 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1 font-semibold text-[10px] tracking-[0.14em] text-[#666666] uppercase";
|
||
const INNER_PANEL_CLASS =
|
||
"rounded-[22px] border border-[#efefef] bg-[#fbfbfb] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]";
|
||
|
||
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}`;
|
||
}
|
||
|
||
type ParameterSliderRowProps = {
|
||
label: string;
|
||
value: number;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
onChange: (value: number) => void;
|
||
formatValue?: (value: number) => string;
|
||
disabled?: boolean;
|
||
};
|
||
|
||
function ParameterSliderRow({
|
||
label,
|
||
value,
|
||
min,
|
||
max,
|
||
step,
|
||
onChange,
|
||
formatValue,
|
||
disabled = false,
|
||
}: ParameterSliderRowProps) {
|
||
const displayValue = formatValue ? formatValue(value) : String(value);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"space-y-3 rounded-[20px] border border-[#efefef] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]",
|
||
disabled && "opacity-50"
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="font-medium text-[12px] text-[#222222] tracking-[-0.01em]">
|
||
{label}
|
||
</div>
|
||
<div className="inline-flex min-w-[54px] justify-center rounded-full border border-[#e7e7e7] bg-white px-2.5 py-1 font-medium text-[11px] text-[#666666] shadow-[0_4px_12px_rgba(0,0,0,0.04)]">
|
||
{displayValue}
|
||
</div>
|
||
</div>
|
||
<Slider
|
||
className="w-full"
|
||
disabled={disabled}
|
||
max={max}
|
||
min={min}
|
||
onValueChange={([next]) => onChange(next ?? value)}
|
||
rangeClassName={SLIDER_RANGE_CLASS}
|
||
step={step}
|
||
thumbClassName={SLIDER_THUMB_CLASS}
|
||
trackClassName={SLIDER_TRACK_CLASS}
|
||
value={[value]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 [settingsOpen, setSettingsOpen] = useState(true);
|
||
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
|
||
const [faceOpen, setFaceOpen] = useState(true);
|
||
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
|
||
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
|
||
const previewDragStateRef = useRef<{
|
||
pointerId: number;
|
||
startX: number;
|
||
startY: number;
|
||
originX: number;
|
||
originY: number;
|
||
} | null>(null);
|
||
|
||
const { data: configPayload, isLoading } = useSWR<{
|
||
success?: boolean;
|
||
config?: EnhanceProClientConfig;
|
||
pointsConfig?: EnhanceProClientPointsConfig;
|
||
}>("/api/enhance/pro/config", fetcher);
|
||
|
||
const config = useMemo(
|
||
() => configPayload?.config || FALLBACK_CONFIG,
|
||
[configPayload?.config]
|
||
);
|
||
const pointsConfig = useMemo(
|
||
() => configPayload?.pointsConfig || FALLBACK_POINTS_CONFIG,
|
||
[configPayload?.pointsConfig]
|
||
);
|
||
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} 张候选结果`
|
||
: "生成后可直接发送回画布";
|
||
const pointsEstimate = useMemo(
|
||
() =>
|
||
calculateEnhanceProPointsEstimate(
|
||
{
|
||
...defaultPointsSystemConfig,
|
||
enabled: pointsConfig.enabled,
|
||
blockOnInsufficient: pointsConfig.blockOnInsufficient,
|
||
enhanceProPricing: pointsConfig.enhanceProPricing,
|
||
},
|
||
{
|
||
width: formState.width,
|
||
height: formState.height,
|
||
batchSize: formState.batchSize,
|
||
model: formState.model,
|
||
}
|
||
),
|
||
[formState.batchSize, formState.height, formState.model, formState.width, pointsConfig]
|
||
);
|
||
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
setPreviewOffset({ x: 0, y: 0 });
|
||
setIsPreviewDragging(false);
|
||
previewDragStateRef.current = null;
|
||
}, [activePreviewUrl]);
|
||
|
||
useEffect(() => {
|
||
if (formState.face_enhancement) {
|
||
setFaceOpen(true);
|
||
}
|
||
}, [formState.face_enhancement]);
|
||
|
||
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 (generatedImages.length > 0 && 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]);
|
||
|
||
const handlePreviewPointerDown = (
|
||
event: ReactPointerEvent<HTMLDivElement>
|
||
) => {
|
||
if (!activePreviewUrl || event.button !== 0) {
|
||
return;
|
||
}
|
||
|
||
previewDragStateRef.current = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
originX: previewOffset.x,
|
||
originY: previewOffset.y,
|
||
};
|
||
setIsPreviewDragging(true);
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
};
|
||
|
||
const handlePreviewPointerMove = (
|
||
event: ReactPointerEvent<HTMLDivElement>
|
||
) => {
|
||
const dragState = previewDragStateRef.current;
|
||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
|
||
setPreviewOffset({
|
||
x: dragState.originX + event.clientX - dragState.startX,
|
||
y: dragState.originY + event.clientY - dragState.startY,
|
||
});
|
||
};
|
||
|
||
const finishPreviewDrag = (event?: ReactPointerEvent<HTMLDivElement>) => {
|
||
const dragState = previewDragStateRef.current;
|
||
if (!dragState) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
event &&
|
||
event.currentTarget.hasPointerCapture(dragState.pointerId)
|
||
) {
|
||
event.currentTarget.releasePointerCapture(dragState.pointerId);
|
||
}
|
||
|
||
previewDragStateRef.current = null;
|
||
setIsPreviewDragging(false);
|
||
};
|
||
|
||
return (
|
||
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
|
||
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden px-6 pb-28 pt-24">
|
||
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
|
||
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025] [mask-image:radial-gradient(circle_at_center,black_38%,transparent_82%)]" />
|
||
{activePreviewUrl ? (
|
||
<div
|
||
className={cn(
|
||
"flex h-full w-full items-center justify-center overflow-hidden touch-none select-none",
|
||
isPreviewDragging ? "cursor-grabbing" : "cursor-grab"
|
||
)}
|
||
onDoubleClick={() => setPreviewOffset({ x: 0, y: 0 })}
|
||
onPointerCancel={finishPreviewDrag}
|
||
onPointerDown={handlePreviewPointerDown}
|
||
onPointerMove={handlePreviewPointerMove}
|
||
onPointerUp={finishPreviewDrag}
|
||
>
|
||
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||
<div className="overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
|
||
<img
|
||
alt={activePreviewLabel}
|
||
className="pointer-events-none max-h-[calc(100vh-17rem)] max-w-[min(1000px,calc(100vw-35rem))] object-contain transition-transform duration-75"
|
||
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
||
style={{
|
||
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3.5 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm">
|
||
<ImageIcon className="h-3.5 w-3.5" />
|
||
<span>{activePreviewLabel}</span>
|
||
</div>
|
||
<div className="pointer-events-none absolute top-6 right-8 hidden items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] lg:inline-flex">
|
||
双击回正
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="relative w-full max-w-[42rem] px-6">
|
||
<div className="relative rounded-[38px] border border-[#e6e6e6] bg-white px-8 py-8 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_30px_60px_rgba(0,0,0,0.06)] backdrop-blur-sm">
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-[24px] border border-[#ececec] bg-[#fafafa] text-[#111111] shadow-[0_14px_28px_rgba(0,0,0,0.06)]">
|
||
<Sparkles className="h-8 w-8" />
|
||
</div>
|
||
<div className="min-w-0 text-left">
|
||
<div className={SECTION_EYEBROW_CLASS}>Enhance Pro Studio</div>
|
||
<div className="mt-3 font-semibold text-[34px] tracking-[-0.05em] text-[#111111] leading-[1.02]">
|
||
还没有可增强的图片
|
||
</div>
|
||
<div className="mt-3 max-w-[30rem] text-[#666666] text-[15px] leading-7">
|
||
先在主画布里选中一张图片,或者先在实时画布模式里生成结果,
|
||
再切回这里进行精修、增强和细节重建。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-7 grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-[24px] border border-[#efefef] bg-[#fafafa] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]">
|
||
<div className="text-[11px] font-semibold tracking-[0.16em] text-[#666666] uppercase">
|
||
Step 01
|
||
</div>
|
||
<div className="mt-2 font-semibold text-[18px] tracking-[-0.03em] text-[#111111]">
|
||
选中一张图
|
||
</div>
|
||
<div className="mt-1 text-[13px] leading-6 text-[#666666]">
|
||
从主画布中选择要增强的图片,系统会自动把它带入当前工作台。
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[24px] border border-[#efefef] bg-[#fafafa] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]">
|
||
<div className="text-[11px] font-semibold tracking-[0.16em] text-[#666666] uppercase">
|
||
Step 02
|
||
</div>
|
||
<div className="mt-2 font-semibold text-[18px] tracking-[-0.03em] text-[#111111]">
|
||
调参后开始增强
|
||
</div>
|
||
<div className="mt-1 text-[13px] leading-6 text-[#666666]">
|
||
使用右侧工具卡片快速选择模式、强度和增强范围,生成后可直接发回画布。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{generatedImages.length > 0 && 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-[#e5e5e5] bg-white/96 px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.05)] 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-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_10px_20px_rgba(0,0,0,0.08)]"
|
||
: "border-[#e5e5e5] 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 className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100%-2rem)] -translate-x-1/2 flex-col items-center gap-2">
|
||
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#666666] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedGeneratedImage ? (
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-[#111111]" />
|
||
) : null}
|
||
{actionHint}
|
||
</span>
|
||
</div>
|
||
{pointsEstimate.chargeable ? (
|
||
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#111111] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
|
||
预计消耗 {pointsEstimate.cost} 积分
|
||
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
|
||
? ` · ${pointsEstimate.bucketName}`
|
||
: ""}
|
||
</div>
|
||
) : null}
|
||
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-[32px] border border-[#e5e5e5] bg-white p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_18px_38px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||
<Button
|
||
className={cn(
|
||
"h-11 min-w-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
|
||
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-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
|
||
SECONDARY_ACTION_BUTTON_CLASS
|
||
)}
|
||
disabled={!selectedGeneratedImage}
|
||
onClick={handleApply}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Send className="mr-2 h-4 w-4" />
|
||
发送到画布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pr-6">
|
||
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
|
||
{isLoading ? (
|
||
<div
|
||
className={cn(
|
||
PANEL_SHELL_CLASS,
|
||
"flex items-center gap-2 px-4 py-3 text-[#666666] text-sm"
|
||
)}
|
||
>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在加载增强配置...
|
||
</div>
|
||
) : null}
|
||
|
||
{!config.enabled && !isLoading ? (
|
||
<div className="rounded-[24px] border border-[#e5e5e5] bg-white px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
|
||
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
|
||
配置。
|
||
</div>
|
||
) : null}
|
||
|
||
{lastErrorMessage ? (
|
||
<div className="rounded-[24px] border border-[#e5e5e5] bg-[#fafafa] px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
|
||
{lastErrorMessage}
|
||
</div>
|
||
) : null}
|
||
|
||
<Collapsible
|
||
className={cn(
|
||
PANEL_SHELL_CLASS,
|
||
SIDEBAR_SECTION_CARD_CLASS,
|
||
"px-3.5 py-3.5"
|
||
)}
|
||
onOpenChange={setSettingsOpen}
|
||
open={settingsOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
|
||
type="button"
|
||
>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
|
||
<Settings2 className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0 space-y-1">
|
||
<div className={SECTION_EYEBROW_CLASS}>Studio Setup</div>
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>设置</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="hidden rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666] sm:inline-flex">
|
||
{formState.model}
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-[#666666] transition-transform",
|
||
settingsOpen && "rotate-180"
|
||
)}
|
||
/>
|
||
</div>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="mt-3 space-y-3">
|
||
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
|
||
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
|
||
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
|
||
<SelectTrigger className={COMPACT_FIELD_CLASS}>
|
||
<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="rounded-[18px] border border-[#ececec] bg-white px-3 py-2.5">
|
||
<div className={cn(PANEL_SECTION_NOTE_CLASS, "line-clamp-2")}>
|
||
{selectedPreset.description ||
|
||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
|
||
<Label
|
||
className={PANEL_SECTION_TITLE_CLASS}
|
||
htmlFor="enhance-workspace-prompt"
|
||
>
|
||
增强提示词
|
||
</Label>
|
||
<Textarea
|
||
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[84px] resize-none rounded-[20px] bg-white text-sm placeholder:text-[#a5a8b5]`}
|
||
id="enhance-workspace-prompt"
|
||
onChange={(event) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
prompt: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
|
||
rows={2}
|
||
value={formState.prompt}
|
||
/>
|
||
</div>
|
||
|
||
<div className={cn(INNER_PANEL_CLASS, "grid grid-cols-4 gap-2.5")}>
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className={COMPACT_LABEL_CLASS}
|
||
htmlFor="enhance-workspace-width"
|
||
>
|
||
宽度
|
||
</Label>
|
||
<Input
|
||
className={COMPACT_FIELD_CLASS}
|
||
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={COMPACT_LABEL_CLASS}
|
||
htmlFor="enhance-workspace-height"
|
||
>
|
||
高度
|
||
</Label>
|
||
<Input
|
||
className={COMPACT_FIELD_CLASS}
|
||
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={COMPACT_LABEL_CLASS}>候选</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
handleNumericField(
|
||
"batchSize",
|
||
clampNumber(
|
||
Number.parseInt(value, 10) || 1,
|
||
1,
|
||
config.maxBatchSize
|
||
)
|
||
)
|
||
}
|
||
value={String(formState.batchSize)}
|
||
>
|
||
<SelectTrigger className={COMPACT_FIELD_CLASS}>
|
||
<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>
|
||
) : null}
|
||
<div className="space-y-1.5">
|
||
<Label className={COMPACT_LABEL_CLASS}>模式</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
model: value as FormState["model"],
|
||
}))
|
||
}
|
||
value={formState.model}
|
||
>
|
||
<SelectTrigger className={COMPACT_FIELD_CLASS}>
|
||
<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={COMPACT_LABEL_CLASS}>格式</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
output_format: value as FormState["output_format"],
|
||
}))
|
||
}
|
||
value={formState.output_format}
|
||
>
|
||
<SelectTrigger className={COMPACT_FIELD_CLASS}>
|
||
<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={COMPACT_LABEL_CLASS}>区域</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
subject_detection:
|
||
value as FormState["subject_detection"],
|
||
}))
|
||
}
|
||
value={formState.subject_detection}
|
||
>
|
||
<SelectTrigger className={COMPACT_FIELD_CLASS}>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
|
||
<SelectItem value="All">全部</SelectItem>
|
||
<SelectItem value="Foreground">前景</SelectItem>
|
||
<SelectItem value="Background">背景</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label
|
||
className={COMPACT_LABEL_CLASS}
|
||
htmlFor="enhance-workspace-seed"
|
||
>
|
||
Seed
|
||
</Label>
|
||
<Input
|
||
className={COMPACT_FIELD_CLASS}
|
||
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={COMPACT_LABEL_CLASS}
|
||
htmlFor="enhance-workspace-scale"
|
||
>
|
||
放大
|
||
</Label>
|
||
<Input
|
||
className={COMPACT_FIELD_CLASS}
|
||
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-2 gap-2.5">
|
||
<div
|
||
className={cn(
|
||
SOLID_PANEL_CARD_CLASS,
|
||
COMPACT_TOGGLE_CARD_CLASS,
|
||
"bg-white"
|
||
)}
|
||
>
|
||
<span className="text-[#111111] text-xs">启用放大</span>
|
||
<Switch
|
||
checked={formState.upscaling_activated}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
upscaling_activated: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
SOLID_PANEL_CARD_CLASS,
|
||
COMPACT_TOGGLE_CARD_CLASS,
|
||
"bg-white"
|
||
)}
|
||
>
|
||
<span className="text-[#111111] text-xs">裁剪铺满</span>
|
||
<Switch
|
||
checked={formState.crop_to_fill}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
crop_to_fill: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{config.enableAdvancedControls ? (
|
||
<Collapsible
|
||
className={cn(
|
||
PANEL_SHELL_CLASS,
|
||
SIDEBAR_SECTION_CARD_CLASS,
|
||
"px-3.5 py-3.5"
|
||
)}
|
||
onOpenChange={setAdjustmentsOpen}
|
||
open={adjustmentsOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
|
||
type="button"
|
||
>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
|
||
<SlidersHorizontal className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0 space-y-1">
|
||
<div className={SECTION_EYEBROW_CLASS}>Fine Tune</div>
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>调节</div>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-[#666666] transition-transform",
|
||
adjustmentsOpen && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="mt-3 space-y-4">
|
||
<ParameterSliderRow
|
||
label="锐化"
|
||
max={1}
|
||
min={0}
|
||
onChange={(value) =>
|
||
handleNumericField("sharpen", clampNumber(value, 0, 1))
|
||
}
|
||
step={0.01}
|
||
value={formState.sharpen}
|
||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||
/>
|
||
<ParameterSliderRow
|
||
label="降噪"
|
||
max={1}
|
||
min={0}
|
||
onChange={(value) =>
|
||
handleNumericField("denoise", clampNumber(value, 0, 1))
|
||
}
|
||
step={0.01}
|
||
value={formState.denoise}
|
||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||
/>
|
||
<ParameterSliderRow
|
||
label="细节"
|
||
max={1}
|
||
min={0}
|
||
onChange={(value) =>
|
||
handleNumericField("detail", clampNumber(value, 0, 1))
|
||
}
|
||
step={0.01}
|
||
value={formState.detail}
|
||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||
/>
|
||
<ParameterSliderRow
|
||
label="创造力"
|
||
max={6}
|
||
min={1}
|
||
onChange={(value) =>
|
||
handleNumericField(
|
||
"creativity",
|
||
clampNumber(Math.round(value), 1, 6)
|
||
)
|
||
}
|
||
step={1}
|
||
value={formState.creativity}
|
||
formatValue={(value) => String(Math.round(value))}
|
||
/>
|
||
<ParameterSliderRow
|
||
label="纹理"
|
||
max={5}
|
||
min={1}
|
||
onChange={(value) =>
|
||
handleNumericField(
|
||
"texture",
|
||
clampNumber(Math.round(value), 1, 5)
|
||
)
|
||
}
|
||
step={1}
|
||
value={formState.texture}
|
||
formatValue={(value) => String(Math.round(value))}
|
||
/>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
) : null}
|
||
|
||
<Collapsible
|
||
className={cn(
|
||
PANEL_SHELL_CLASS,
|
||
SIDEBAR_SECTION_CARD_CLASS,
|
||
"px-3.5 py-3.5"
|
||
)}
|
||
onOpenChange={setFaceOpen}
|
||
open={faceOpen}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
|
||
type="button"
|
||
>
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
|
||
<ScanFace className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0 space-y-1">
|
||
<div className={SECTION_EYEBROW_CLASS}>Face Boost</div>
|
||
<div className={PANEL_SECTION_TITLE_CLASS}>面部增强</div>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 shrink-0 text-[#666666] transition-transform",
|
||
faceOpen && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<Switch
|
||
checked={formState.face_enhancement}
|
||
onCheckedChange={(checked) =>
|
||
setFormState((prev) => ({
|
||
...prev,
|
||
face_enhancement: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<CollapsibleContent className="mt-3 space-y-4">
|
||
<ParameterSliderRow
|
||
disabled={!formState.face_enhancement}
|
||
label="脸部力度"
|
||
max={1}
|
||
min={0}
|
||
onChange={(value) =>
|
||
handleNumericField(
|
||
"face_enhancement_strength",
|
||
clampNumber(value, 0, 1)
|
||
)
|
||
}
|
||
step={0.01}
|
||
value={formState.face_enhancement_strength}
|
||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||
/>
|
||
<ParameterSliderRow
|
||
disabled={!formState.face_enhancement}
|
||
label="脸部创意"
|
||
max={1}
|
||
min={0}
|
||
onChange={(value) =>
|
||
handleNumericField(
|
||
"face_enhancement_creativity",
|
||
clampNumber(value, 0, 1)
|
||
)
|
||
}
|
||
step={0.01}
|
||
value={formState.face_enhancement_creativity}
|
||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||
/>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default EnhanceProWorkspace;
|