"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; 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)]"; 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, 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 (
{label}
{displayValue}
onChange(next ?? value)} rangeClassName={SLIDER_RANGE_CLASS} step={step} thumbClassName={SLIDER_THUMB_CLASS} trackClassName={SLIDER_TRACK_CLASS} value={[value]} />
); } 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; onRequestAddImage?: () => void; onApplyResult: (payload: { imageUrl: string; sourceImageUrl?: string; prompt: string; title?: string; model?: string; width?: number; height?: number; }) => void; } export function EnhanceProWorkspace({ sourceImageUrl, onRequestAddImage, onApplyResult, }: EnhanceProWorkspaceProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 }); const [selectedPresetId, setSelectedPresetId] = useState(""); const [selectedResultIndex, setSelectedResultIndex] = useState(-1); const [formState, setFormState] = useState(() => 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, 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(() => { setPreviewOffset({ x: 0, y: 0 }); setIsPreviewDragging(false); previewDragStateRef.current = null; }, [activePreviewUrl]); useEffect(() => { if (formState.face_enhancement) { setFaceOpen(true); } }, [formState.face_enhancement]); const handleNumericField = ( 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, 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), }); 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, 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 handlePreviewPointerDown = ( event: ReactPointerEvent ) => { 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 ) => { 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) => { 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 (
{activePreviewUrl ? (
setPreviewOffset({ x: 0, y: 0 })} onPointerCancel={finishPreviewDrag} onPointerDown={handlePreviewPointerDown} onPointerMove={handlePreviewPointerMove} onPointerUp={finishPreviewDrag} >
{activePreviewLabel}
{activePreviewLabel}
双击回正
) : (
选择本地图片后即可开始图像增强 Pro
)} {generatedImages.length > 0 && sidePreviewItems.length > 0 ? (
{sidePreviewItems.map((item, index) => { const isActive = item.isSource ? selectedResultIndex < 0 : selectedResultIndex === index - 1; return ( ); })}
) : null}
{selectedGeneratedImage ? ( ) : null} {actionHint}
{pointsEstimate.chargeable ? (
预计消耗 {pointsEstimate.cost} 积分 {pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName ? ` · ${pointsEstimate.bucketName}` : ""} {pointsEstimate.chargeMode === "scale_factor" ? ` · ${pointsEstimate.scaleFactor}x` : ""}
) : null}
{isLoading ? (
正在加载增强配置...
) : null} {!config.enabled && !isLoading ? (
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea 配置。
) : null} {lastErrorMessage ? (
{lastErrorMessage}
) : null}
{selectedPreset ? (
{selectedPreset.description || `${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
) : null}