1795 lines
66 KiB
TypeScript
1795 lines
66 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
History as HistoryIcon,
|
|
ImageIcon,
|
|
Loader2,
|
|
ScanFace,
|
|
Send,
|
|
Settings2,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
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());
|
|
|
|
function createEnhanceProClientRequestId() {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function shouldPreserveEnhanceProRequestId(message: string) {
|
|
return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test(
|
|
String(message || "")
|
|
);
|
|
}
|
|
|
|
function buildEnhanceProRequestSignature(params: EnhanceProRequestParams) {
|
|
return JSON.stringify({
|
|
imageUrl: params.imageUrl || null,
|
|
image_base64: params.image_base64 || null,
|
|
mimeType: params.mimeType || null,
|
|
prompt: params.prompt || "",
|
|
width: params.width,
|
|
height: params.height,
|
|
batchSize: params.batchSize || 1,
|
|
seed: params.seed || 0,
|
|
output_format: params.output_format || null,
|
|
subject_detection: params.subject_detection || null,
|
|
face_enhancement: params.face_enhancement === true,
|
|
face_enhancement_creativity: params.face_enhancement_creativity ?? null,
|
|
face_enhancement_strength: params.face_enhancement_strength ?? null,
|
|
crop_to_fill: params.crop_to_fill === true,
|
|
model: params.model || null,
|
|
upscaling_activated: params.upscaling_activated === true,
|
|
image_scaling_factor: params.image_scaling_factor ?? 1,
|
|
creativity: params.creativity ?? null,
|
|
texture: params.texture ?? null,
|
|
sharpen: params.sharpen ?? null,
|
|
denoise: params.denoise ?? null,
|
|
detail: params.detail ?? null,
|
|
});
|
|
}
|
|
|
|
type EnhanceProClientConfig = {
|
|
enabled: boolean;
|
|
providerName: string;
|
|
baseUrl: string;
|
|
defaultModel: EnhanceProKreaModel;
|
|
defaultOutputFormat: EnhanceProOutputFormat;
|
|
allowBatchSize: boolean;
|
|
maxBatchSize: number;
|
|
maxUpscaleFactor: 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,
|
|
maxUpscaleFactor: 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)]";
|
|
const ENHANCE_PRO_HISTORY_STORAGE_KEY = "ai-chatbot:enhance-pro-history:v1";
|
|
const ENHANCE_PRO_HISTORY_LIMIT = 4;
|
|
const ENHANCE_PRO_HISTORY_TOTAL_LIMIT = 48;
|
|
|
|
type EnhanceProHistoryEntry = {
|
|
id: string;
|
|
scopeKey: string;
|
|
createdAt: number;
|
|
prompt: string;
|
|
model: string;
|
|
image: EnhanceProImageResult;
|
|
};
|
|
|
|
function clampNumber(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function hashEnhanceHistoryValue(value: string) {
|
|
let hash = 2_166_136_261;
|
|
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 16_777_619);
|
|
}
|
|
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
function createEnhanceProHistoryScopeKey(value?: string | null) {
|
|
const raw = String(value || "").trim();
|
|
return raw
|
|
? `enhance-scope-${hashEnhanceHistoryValue(raw)}`
|
|
: "enhance-scope-global";
|
|
}
|
|
|
|
function normalizeEnhanceProHistoryEntry(
|
|
value: unknown
|
|
): EnhanceProHistoryEntry | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const raw = value as Partial<EnhanceProHistoryEntry> & {
|
|
image?: Partial<EnhanceProImageResult>;
|
|
};
|
|
const originalUrl = String(raw.image?.originalUrl || "").trim();
|
|
|
|
if (!originalUrl) return null;
|
|
|
|
return {
|
|
id:
|
|
String(raw.id || "").trim() ||
|
|
`enhance-history-${hashEnhanceHistoryValue(originalUrl)}`,
|
|
scopeKey:
|
|
String(raw.scopeKey || "").trim() || createEnhanceProHistoryScopeKey(),
|
|
createdAt: Number.isFinite(Number(raw.createdAt))
|
|
? Number(raw.createdAt)
|
|
: Date.now(),
|
|
prompt: String(raw.prompt || "").trim(),
|
|
model: String(raw.model || "").trim(),
|
|
image: {
|
|
originalUrl,
|
|
thumbnailUrl: String(raw.image?.thumbnailUrl || originalUrl).trim(),
|
|
width: Number.isFinite(Number(raw.image?.width))
|
|
? Number(raw.image?.width)
|
|
: undefined,
|
|
height: Number.isFinite(Number(raw.image?.height))
|
|
? Number(raw.image?.height)
|
|
: undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function sortEnhanceHistoryEntries(entries: EnhanceProHistoryEntry[]) {
|
|
return [...entries].sort((left, right) => right.createdAt - left.createdAt);
|
|
}
|
|
|
|
function dedupeEnhanceHistoryEntries(entries: EnhanceProHistoryEntry[]) {
|
|
const seen = new Set<string>();
|
|
const nextEntries: EnhanceProHistoryEntry[] = [];
|
|
|
|
for (const entry of sortEnhanceHistoryEntries(entries)) {
|
|
const originalUrl = String(entry.image.originalUrl || "").trim();
|
|
const dedupeKey = `${entry.scopeKey}::${originalUrl}`;
|
|
if (!originalUrl || seen.has(dedupeKey)) continue;
|
|
seen.add(dedupeKey);
|
|
nextEntries.push(entry);
|
|
}
|
|
|
|
return nextEntries;
|
|
}
|
|
|
|
function readPersistedEnhanceProHistory() {
|
|
if (typeof window === "undefined") return [] as EnhanceProHistoryEntry[];
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(ENHANCE_PRO_HISTORY_STORAGE_KEY);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return dedupeEnhanceHistoryEntries(
|
|
parsed
|
|
.map((entry) => normalizeEnhanceProHistoryEntry(entry))
|
|
.filter((entry): entry is EnhanceProHistoryEntry => Boolean(entry))
|
|
).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writePersistedEnhanceProHistory(entries: EnhanceProHistoryEntry[]) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(
|
|
ENHANCE_PRO_HISTORY_STORAGE_KEY,
|
|
JSON.stringify(entries)
|
|
);
|
|
}
|
|
|
|
function mergePersistedEnhanceProHistory(
|
|
currentEntries: EnhanceProHistoryEntry[],
|
|
scopeKey: string,
|
|
nextEntries: EnhanceProHistoryEntry[]
|
|
) {
|
|
const currentScopeEntries = dedupeEnhanceHistoryEntries([
|
|
...nextEntries.filter((entry) => entry.scopeKey === scopeKey),
|
|
...currentEntries.filter((entry) => entry.scopeKey === scopeKey),
|
|
]).slice(0, ENHANCE_PRO_HISTORY_LIMIT);
|
|
const otherScopeEntries = dedupeEnhanceHistoryEntries(
|
|
currentEntries.filter((entry) => entry.scopeKey !== scopeKey)
|
|
);
|
|
|
|
return sortEnhanceHistoryEntries([
|
|
...currentScopeEntries,
|
|
...otherScopeEntries,
|
|
]).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
|
|
}
|
|
|
|
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,
|
|
config.maxUpscaleFactor
|
|
),
|
|
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
|
|
texture: clampNumber(Math.round(preset.texture), 1, 5),
|
|
sharpen: clampNumber(preset.sharpen, 0, 1),
|
|
denoise: clampNumber(preset.denoise, 0, 1),
|
|
detail: clampNumber(preset.detail, 0, 1),
|
|
};
|
|
}
|
|
|
|
function 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;
|
|
historyScopeKey?: string | null;
|
|
onRequestAddImage?: () => void;
|
|
onApplyResult: (payload: {
|
|
imageUrl: string;
|
|
sourceImageUrl?: string;
|
|
prompt: string;
|
|
title?: string;
|
|
model?: string;
|
|
width?: number;
|
|
height?: number;
|
|
}) => void;
|
|
}
|
|
|
|
export function EnhanceProWorkspace({
|
|
sourceImageUrl,
|
|
historyScopeKey,
|
|
onRequestAddImage,
|
|
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 [comparePosition, setComparePosition] = useState(50);
|
|
const [persistedHistoryEntries, setPersistedHistoryEntries] = useState<
|
|
EnhanceProHistoryEntry[]
|
|
>(() => readPersistedEnhanceProHistory());
|
|
const compareDragPointerIdRef = useRef<number | null>(null);
|
|
const requestIdRef = useRef<string | null>(null);
|
|
const requestSignatureRef = useRef<string | 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 resolvedHistoryScopeInput = useMemo(
|
|
() => String(historyScopeKey || sourceImageUrl || "").trim(),
|
|
[historyScopeKey, sourceImageUrl]
|
|
);
|
|
const resolvedHistoryScopeKey = useMemo(
|
|
() => createEnhanceProHistoryScopeKey(resolvedHistoryScopeInput),
|
|
[resolvedHistoryScopeInput]
|
|
);
|
|
const visibleHistoryEntries = useMemo(() => {
|
|
const orderedEntries = sortEnhanceHistoryEntries(persistedHistoryEntries);
|
|
if (!resolvedHistoryScopeInput) {
|
|
return orderedEntries.slice(0, ENHANCE_PRO_HISTORY_LIMIT);
|
|
}
|
|
return orderedEntries
|
|
.filter((entry) => entry.scopeKey === resolvedHistoryScopeKey)
|
|
.slice(0, ENHANCE_PRO_HISTORY_LIMIT);
|
|
}, [
|
|
persistedHistoryEntries,
|
|
resolvedHistoryScopeInput,
|
|
resolvedHistoryScopeKey,
|
|
]);
|
|
const activePreviewUrl =
|
|
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
|
|
const showComparisonPreview = Boolean(
|
|
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,
|
|
upscalingActivated: formState.upscaling_activated,
|
|
scaleFactor: formState.image_scaling_factor,
|
|
}
|
|
),
|
|
[
|
|
formState.batchSize,
|
|
formState.height,
|
|
formState.image_scaling_factor,
|
|
formState.model,
|
|
formState.upscaling_activated,
|
|
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(() => {
|
|
setComparePosition(50);
|
|
}, [selectedResultIndex]);
|
|
|
|
useEffect(() => {
|
|
if (formState.face_enhancement) {
|
|
setFaceOpen(true);
|
|
}
|
|
}, [formState.face_enhancement]);
|
|
|
|
const persistHistoryEntries = useCallback(
|
|
(updater: (entries: EnhanceProHistoryEntry[]) => EnhanceProHistoryEntry[]) => {
|
|
setPersistedHistoryEntries((currentEntries) => {
|
|
const nextEntries = dedupeEnhanceHistoryEntries(
|
|
updater(currentEntries)
|
|
).slice(0, ENHANCE_PRO_HISTORY_TOTAL_LIMIT);
|
|
writePersistedEnhanceProHistory(nextEntries);
|
|
return nextEntries;
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
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("");
|
|
const requestParams: EnhanceProRequestParams = {
|
|
...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,
|
|
config.maxUpscaleFactor
|
|
),
|
|
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
|
|
texture: clampNumber(Math.round(formState.texture), 1, 5),
|
|
sharpen: clampNumber(formState.sharpen, 0, 1),
|
|
denoise: clampNumber(formState.denoise, 0, 1),
|
|
detail: clampNumber(formState.detail, 0, 1),
|
|
};
|
|
const requestSignature = buildEnhanceProRequestSignature(requestParams);
|
|
const clientRequestId =
|
|
requestIdRef.current && requestSignatureRef.current === requestSignature
|
|
? requestIdRef.current
|
|
: createEnhanceProClientRequestId();
|
|
requestIdRef.current = clientRequestId;
|
|
requestSignatureRef.current = requestSignature;
|
|
try {
|
|
const result = await imageGenerationService.enhanceImagePro({
|
|
...requestParams,
|
|
clientRequestId,
|
|
});
|
|
|
|
if (!result.success || !result.data?.images?.length) {
|
|
throw new Error(result.error || "图像增强 Pro 失败");
|
|
}
|
|
|
|
const nextImages = result.data.images;
|
|
const historyCreatedAt = Date.now();
|
|
|
|
setGeneratedImages(nextImages);
|
|
setSelectedResultIndex(0);
|
|
persistHistoryEntries((currentEntries) =>
|
|
mergePersistedEnhanceProHistory(
|
|
currentEntries,
|
|
resolvedHistoryScopeKey,
|
|
nextImages.map((image, index) => ({
|
|
id: [
|
|
resolvedHistoryScopeKey,
|
|
historyCreatedAt.toString(36),
|
|
String(index),
|
|
hashEnhanceHistoryValue(image.originalUrl || image.thumbnailUrl),
|
|
].join("-"),
|
|
scopeKey: resolvedHistoryScopeKey,
|
|
createdAt: historyCreatedAt + index,
|
|
prompt: requestParams.prompt || "",
|
|
model: requestParams.model || config.defaultModel,
|
|
image,
|
|
}))
|
|
)
|
|
);
|
|
requestIdRef.current = null;
|
|
requestSignatureRef.current = null;
|
|
toast.success(
|
|
`图像增强 Pro 完成,已生成 ${nextImages.length} 张结果`
|
|
);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "图像增强 Pro 失败";
|
|
if (!shouldPreserveEnhanceProRequestId(message)) {
|
|
requestIdRef.current = null;
|
|
requestSignatureRef.current = null;
|
|
}
|
|
setLastErrorMessage(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleLoadHistoryEntry = useCallback((entry: EnhanceProHistoryEntry) => {
|
|
requestIdRef.current = null;
|
|
requestSignatureRef.current = null;
|
|
setGeneratedImages([entry.image]);
|
|
setSelectedResultIndex(0);
|
|
setLastErrorMessage("");
|
|
toast.success("已加载历史增强图片");
|
|
}, []);
|
|
|
|
const handleDeleteHistoryEntry = useCallback(
|
|
(entryId: string) => {
|
|
persistHistoryEntries((currentEntries) =>
|
|
currentEntries.filter((entry) => entry.id !== entryId)
|
|
);
|
|
toast.success("已删除历史增强图片");
|
|
},
|
|
[persistHistoryEntries]
|
|
);
|
|
|
|
const handleApply = () => {
|
|
if (!selectedGeneratedImage?.originalUrl) {
|
|
toast.error("请先生成结果并选择一张增强图");
|
|
return;
|
|
}
|
|
onApplyResult({
|
|
imageUrl: selectedGeneratedImage.originalUrl,
|
|
sourceImageUrl: sourceImageUrl || undefined,
|
|
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 updateComparePositionFromPointer = (
|
|
event: ReactPointerEvent<HTMLDivElement>
|
|
) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
if (rect.width <= 0) return;
|
|
const nextPosition = ((event.clientX - rect.left) / rect.width) * 100;
|
|
setComparePosition(clampNumber(nextPosition, 0, 100));
|
|
};
|
|
|
|
const handleComparePointerDown = (
|
|
event: ReactPointerEvent<HTMLDivElement>
|
|
) => {
|
|
if (event.button !== 0) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
compareDragPointerIdRef.current = event.pointerId;
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
updateComparePositionFromPointer(event);
|
|
};
|
|
|
|
const handleComparePointerMove = (
|
|
event: ReactPointerEvent<HTMLDivElement>
|
|
) => {
|
|
if (compareDragPointerIdRef.current !== event.pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
updateComparePositionFromPointer(event);
|
|
};
|
|
|
|
const finishCompareDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (compareDragPointerIdRef.current !== event.pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
compareDragPointerIdRef.current = null;
|
|
};
|
|
|
|
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="relative flex h-full w-full min-w-0 select-none items-center justify-center overflow-hidden">
|
|
<div className="relative flex h-full w-full min-w-0 items-center justify-center overflow-hidden px-4 py-4">
|
|
<div className="relative h-full w-full max-w-[min(1320px,calc(100vw-30rem))] overflow-hidden">
|
|
<img
|
|
alt={activePreviewLabel}
|
|
className="pointer-events-none absolute inset-0 h-full w-full object-contain transition-transform duration-75"
|
|
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
|
/>
|
|
{showComparisonPreview && sourceImageUrl ? (
|
|
<>
|
|
<div
|
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
style={{
|
|
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
|
|
}}
|
|
>
|
|
<img
|
|
alt="原图对比"
|
|
className="h-full w-full object-contain"
|
|
src={getDisplayImageUrl(sourceImageUrl, 1800)}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_18px_rgba(0,0,0,0.35)]"
|
|
style={{ left: `${comparePosition}%` }}
|
|
/>
|
|
<div
|
|
className="pointer-events-auto absolute inset-0 cursor-col-resize touch-none"
|
|
data-enhance-pro-compare-control="true"
|
|
onClick={(event) => event.stopPropagation()}
|
|
onPointerCancel={finishCompareDrag}
|
|
onPointerDown={handleComparePointerDown}
|
|
onPointerMove={handleComparePointerMove}
|
|
onPointerUp={finishCompareDrag}
|
|
>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
|
|
style={{ left: `${comparePosition}%` }}
|
|
>
|
|
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.25),0_0_18px_rgba(0,0,0,0.3)]" />
|
|
<div className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex h-9 w-9 items-center justify-center rounded-full border border-white/80 bg-black/70 text-white shadow-[0_12px_24px_rgba(0,0,0,0.28)] backdrop-blur-sm">
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
{showComparisonPreview ? (
|
|
<>
|
|
<div className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
|
原图
|
|
</div>
|
|
<div className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
|
增强后
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</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 flex flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<Button
|
|
className={cn(
|
|
"h-16 rounded-[22px] px-10 text-[15px] font-semibold tracking-[-0.02em] sm:h-20 sm:px-12 sm:text-lg",
|
|
PRIMARY_ACTION_BUTTON_CLASS
|
|
)}
|
|
disabled={!onRequestAddImage}
|
|
onClick={onRequestAddImage}
|
|
type="button"
|
|
>
|
|
<ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
|
|
添加图片
|
|
</Button>
|
|
<div className="text-[#666666] text-[13px] leading-6">
|
|
选择本地图片后即可开始图像增强 Pro
|
|
</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}`
|
|
: ""}
|
|
{pointsEstimate.chargeMode === "scale_factor"
|
|
? ` · ${pointsEstimate.scaleFactor}x`
|
|
: ""}
|
|
</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}
|
|
|
|
{visibleHistoryEntries.length > 0 ? (
|
|
<div
|
|
className={cn(
|
|
PANEL_SHELL_CLASS,
|
|
SIDEBAR_SECTION_CARD_CLASS,
|
|
"space-y-3 px-3.5 py-3.5"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<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)]">
|
|
<HistoryIcon className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 space-y-1">
|
|
<div className={SECTION_EYEBROW_CLASS}>Recent Enhancements</div>
|
|
<div className={PANEL_SECTION_TITLE_CLASS}>最近增强</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
|
|
{visibleHistoryEntries.length}/{ENHANCE_PRO_HISTORY_LIMIT}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
{visibleHistoryEntries.map((entry, index) => {
|
|
const isActive =
|
|
selectedGeneratedImage?.originalUrl === entry.image.originalUrl;
|
|
const createdAtLabel = new Date(entry.createdAt).toLocaleString(
|
|
"zh-CN",
|
|
{
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"group flex items-stretch gap-2 rounded-[22px] border border-[#efefef] bg-[#fafafa] p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)] transition",
|
|
isActive &&
|
|
"border-[#111111] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_12px_26px_rgba(0,0,0,0.06)]"
|
|
)}
|
|
key={entry.id}
|
|
>
|
|
<button
|
|
className="flex min-w-0 flex-1 items-stretch gap-3 text-left"
|
|
onClick={() => handleLoadHistoryEntry(entry)}
|
|
type="button"
|
|
>
|
|
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-[18px] border border-[#ebebeb] bg-white">
|
|
<img
|
|
alt={`历史增强 ${index + 1}`}
|
|
className="h-full w-full object-cover"
|
|
src={getDisplayImageUrl(
|
|
entry.image.thumbnailUrl || entry.image.originalUrl,
|
|
320
|
|
)}
|
|
/>
|
|
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-0.5 font-medium text-[10px] text-white backdrop-blur-sm">
|
|
最近第 {index + 1} 张
|
|
</div>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
|
<div className="flex items-center gap-2">
|
|
<div className="truncate font-medium text-[#111111] text-sm tracking-[-0.02em]">
|
|
增强结果
|
|
</div>
|
|
{isActive ? (
|
|
<span className="rounded-full border border-[#e5e5e5] bg-white px-2 py-0.5 text-[10px] text-[#111111]">
|
|
当前
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-1 text-[#666666] text-[11px]">
|
|
{createdAtLabel}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[#666666] text-[11px]">
|
|
{entry.model ? (
|
|
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-0.5">
|
|
{entry.model}
|
|
</span>
|
|
) : null}
|
|
{entry.image.width && entry.image.height ? (
|
|
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-0.5">
|
|
{entry.image.width} x {entry.image.height}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleDeleteHistoryEntry(entry.id);
|
|
}}
|
|
title="删除历史图片"
|
|
type="button"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</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={config.maxUpscaleFactor}
|
|
min="1"
|
|
onChange={(event) =>
|
|
handleNumericField(
|
|
"image_scaling_factor",
|
|
clampNumber(
|
|
Number.parseFloat(event.target.value) || 1,
|
|
1,
|
|
config.maxUpscaleFactor
|
|
)
|
|
)
|
|
}
|
|
step="0.25"
|
|
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;
|