feat: add enhance pro canvas workflow and presets
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-04 22:40:50 +08:00
parent d76b31952f
commit 670a50c4d0
9 changed files with 3198 additions and 6 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,10 @@ import {
normalizeThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
} from "@/lib/sqlserver/config";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
} from "@/lib/enhance-pro/config";
const settingSchema = z.object({
key: z.string(),
@ -223,6 +227,31 @@ export async function POST(request: Request) {
nextValue = normalizeProductPlanningAiModelConfig(value);
}
if (key === ENHANCE_PRO_KREA_SETTING_KEY) {
const existingSetting = await getSystemSetting({
key: ENHANCE_PRO_KREA_SETTING_KEY,
}).catch(() => null);
const existingValue =
existingSetting?.value && typeof existingSetting.value === "object"
? (existingSetting.value as Record<string, unknown>)
: {};
const normalizedValue = normalizeEnhanceProKreaConfig(
value,
String(existingValue.apiKey || "").trim(),
);
if (normalizedValue.enabled) {
if (!normalizedValue.baseUrl) {
return new ChatSDKError("bad_request:api", "请填写 Krea API 地址").toResponse();
}
if (!normalizedValue.apiKey) {
return new ChatSDKError("bad_request:api", "请填写 Krea API Key").toResponse();
}
}
nextValue = normalizedValue;
}
const setting = await upsertSystemSetting({
key,
value: nextValue,

View File

@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
sanitizeEnhanceProKreaConfigForClient,
} from "@/lib/enhance-pro/config";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const setting = await getSystemSetting({ key: ENHANCE_PRO_KREA_SETTING_KEY });
const config = normalizeEnhanceProKreaConfig(setting?.value);
return NextResponse.json({
success: true,
config: sanitizeEnhanceProKreaConfigForClient(config),
});
} catch (error) {
console.error("[Enhance Pro Config] 获取配置失败:", error);
return NextResponse.json({ error: "获取图像增强 Pro 配置失败" }, { status: 500 });
}
}

View File

@ -0,0 +1,660 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { generatedImage } from "@/lib/db/schema";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
type EnhanceProKreaConfig,
type EnhanceProKreaModel,
type EnhanceProOutputFormat,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { uploadFile } from "@/lib/storage/service";
const ENHANCE_PRO_CREATE_TIMEOUT_MS = 120_000;
const ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS = 20_000;
const ENHANCE_PRO_TASK_POLL_INTERVAL_MS = 1_500;
const ENHANCE_PRO_MODEL_ID = "krea-topaz-generative";
const ENHANCE_PRO_MAX_RESULT_IMAGES = 4;
type EnhanceProRequestBody = {
imageUrl?: string;
image_base64?: string;
mimeType?: string;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
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;
};
type FinalizedImageResult = {
thumbnailUrl: string;
originalUrl: string;
width?: number;
height?: number;
};
function clampNumber(value: unknown, min: number, max: number, fallback: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function clampInteger(value: unknown, min: number, max: number, fallback: number) {
return Math.round(clampNumber(value, min, max, fallback));
}
function normalizeBoolean(value: unknown, fallback = false) {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
function normalizeJobStatus(value: unknown) {
return String(value || "").trim().toLowerCase();
}
function buildEnhanceProTaskPendingResponse(taskId: string) {
return NextResponse.json(
{
success: true,
processing: true,
status: "running",
taskId,
pollAfterMs: ENHANCE_PRO_TASK_POLL_INTERVAL_MS,
},
{ status: 202 }
);
}
function normalizeBaseUrl(baseUrl: string) {
return String(baseUrl || "").trim().replace(/\/+$/, "");
}
function sanitizeDimension(value: unknown, fallback: number) {
return clampInteger(value, 1, 32000, fallback);
}
function clampDimensions(width: number, height: number, maxEdge: number) {
const safeWidth = sanitizeDimension(width, 1024);
const safeHeight = sanitizeDimension(height, 1024);
const longestEdge = Math.max(safeWidth, safeHeight);
if (longestEdge <= maxEdge) {
return { width: safeWidth, height: safeHeight };
}
const scale = maxEdge / longestEdge;
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
return await sharp.default(buffer)
.resize(500, 500, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82 })
.toBuffer();
} catch (error) {
console.error("[Enhance Pro] 生成缩略图失败,回退使用原图:", error);
return buffer;
}
}
async function uploadBufferPairToStorage(
buffer: Buffer,
mimeType = "image/jpeg",
prefix = "enhance-pro",
): Promise<{ thumbnail: string; original: string }> {
const ext = mimeType.split("/")[1] || "jpg";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.${ext}`;
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.jpg`;
const thumbnailBuffer = await createThumbnail(buffer);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(
thumbnailFilename,
thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength,
) as ArrayBuffer,
),
uploadFile(
originalFilename,
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
),
]);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
}
async function getImageDimensionsFromBuffer(buffer: Buffer) {
try {
const sharp = await import("sharp");
const metadata = await sharp.default(buffer).metadata();
return {
width: metadata.width || 1024,
height: metadata.height || 1024,
};
} catch (error) {
console.error("[Enhance Pro] 获取图片尺寸失败,回退默认值:", error);
return {
width: 1024,
height: 1024,
};
}
}
async function fetchBufferFromUrl(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`无法下载图片资源: ${response.status} ${response.statusText}`);
}
const mimeType = response.headers.get("content-type") || "image/jpeg";
const arrayBuffer = await response.arrayBuffer();
return {
buffer: Buffer.from(arrayBuffer),
mimeType,
};
}
async function resolveSourceInput(body: EnhanceProRequestBody) {
const imageUrl = String(body.imageUrl || "").trim();
const imageBase64 = String(body.image_base64 || "").trim();
const requestedMimeType = String(body.mimeType || "").trim() || "image/jpeg";
if (!imageUrl && !imageBase64) {
throw new Error("缺少 imageUrl 或 image_base64 参数");
}
if (imageUrl) {
const hasRequestedDimensions = Number.isFinite(Number(body.width)) && Number.isFinite(Number(body.height));
if (hasRequestedDimensions) {
return {
sourceImageUrl: imageUrl,
width: sanitizeDimension(body.width, 1024),
height: sanitizeDimension(body.height, 1024),
};
}
const downloaded = await fetchBufferFromUrl(imageUrl);
const dimensions = await getImageDimensionsFromBuffer(downloaded.buffer);
return {
sourceImageUrl: imageUrl,
width: dimensions.width,
height: dimensions.height,
};
}
const sourceBuffer = Buffer.from(imageBase64, "base64");
const stored = await uploadBufferPairToStorage(sourceBuffer, requestedMimeType, "enhance-pro-source");
const dimensions = await getImageDimensionsFromBuffer(sourceBuffer);
return {
sourceImageUrl: stored.original,
width: dimensions.width,
height: dimensions.height,
};
}
async function createEnhanceProJob(params: {
config: EnhanceProKreaConfig;
requestBody: EnhanceProRequestBody;
sourceImageUrl: string;
width: number;
height: number;
}) {
const apiUrl = normalizeBaseUrl(params.config.baseUrl);
const requestPayload = {
width: params.width,
height: params.height,
image_url: params.sourceImageUrl,
batchSize: params.requestBody.batchSize,
seed: params.requestBody.seed,
prompt: String(params.requestBody.prompt || "").trim(),
output_format: params.requestBody.output_format || params.config.defaultOutputFormat,
subject_detection: params.requestBody.subject_detection || "All",
face_enhancement: normalizeBoolean(params.requestBody.face_enhancement, false),
face_enhancement_creativity: clampNumber(
params.requestBody.face_enhancement_creativity,
0,
1,
0.5,
),
face_enhancement_strength: clampNumber(
params.requestBody.face_enhancement_strength,
0,
1,
0.5,
),
crop_to_fill: normalizeBoolean(params.requestBody.crop_to_fill, false),
model: params.requestBody.model || params.config.defaultModel,
upscaling_activated: normalizeBoolean(params.requestBody.upscaling_activated, false),
image_scaling_factor: clampNumber(params.requestBody.image_scaling_factor, 1, 32, 1),
creativity: clampInteger(params.requestBody.creativity, 1, 6, 3),
texture: clampInteger(params.requestBody.texture, 1, 5, 3),
sharpen: clampNumber(params.requestBody.sharpen, 0, 1, 0.5),
denoise: clampNumber(params.requestBody.denoise, 0, 1, 0.5),
detail: clampNumber(params.requestBody.detail, 0, 1, 0.5),
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ENHANCE_PRO_CREATE_TIMEOUT_MS);
try {
const response = await fetch(`${apiUrl}/generate/enhance/topaz/generative-enhance`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.config.apiKey}`,
},
body: JSON.stringify(requestPayload),
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`创建任务失败: ${response.status} ${detail || response.statusText}`);
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
async function pollEnhanceProJob(
config: EnhanceProKreaConfig,
jobId: string,
delay = ENHANCE_PRO_TASK_POLL_INTERVAL_MS,
timeout = ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS,
): Promise<any> {
const apiUrl = normalizeBaseUrl(config.baseUrl);
const response = await fetch(`${apiUrl}/jobs/${jobId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
cache: "no-store",
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`查询任务状态失败: ${response.status} ${detail || response.statusText}`);
}
const payload = await response.json();
const status = normalizeJobStatus(payload?.status);
if (status === "completed") {
return payload;
}
if (status === "failed" || status === "cancelled") {
throw new Error(`Krea 任务${status === "failed" ? "失败" : "已取消"}`);
}
if (timeout <= 0) {
throw new Error("轮询超时");
}
await new Promise((resolve) => setTimeout(resolve, delay));
return pollEnhanceProJob(config, jobId, delay, timeout - delay);
}
function isEnhanceProTaskStillProcessing(error: unknown) {
return error instanceof Error && /轮询超时/i.test(error.message || "");
}
async function saveGeneratedImageRecord(params: {
userId: string;
chatId?: string | null;
messageId?: string | null;
prompt: string;
originalUrl: string;
thumbnailUrl: string;
imageSize: string;
}) {
const [savedImage] = await db
.insert(generatedImage)
.values({
userId: params.userId,
chatId: params.chatId || null,
messageId: params.messageId || null,
thumbnailUrl: params.thumbnailUrl,
originalUrl: params.originalUrl,
imageSize: params.imageSize,
prompt: params.prompt,
modelId: ENHANCE_PRO_MODEL_ID,
})
.returning();
return savedImage;
}
async function finalizeEnhanceProTaskResult(params: {
userId: string;
taskResult: any;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
startedAt?: number;
sourceImageUrls?: string[];
width: number;
height: number;
}) {
const resultUrls = Array.isArray(params.taskResult?.result?.urls)
? params.taskResult.result.urls.filter((url: unknown) => typeof url === "string" && url)
: [];
if (resultUrls.length === 0) {
throw new Error("Krea 任务未返回结果图片");
}
const limitedUrls = resultUrls.slice(0, ENHANCE_PRO_MAX_RESULT_IMAGES);
const finalPrompt = String(params.prompt || "").trim() || "图像增强 Pro";
const imageSize = `${params.width}x${params.height}`;
const sourceAssets =
Array.isArray(params.sourceImageUrls) && params.sourceImageUrls.length > 0
? await getMediaAssetsByUserAndUrls({
userId: params.userId,
urls: params.sourceImageUrls,
})
: [];
const finalizedImages: FinalizedImageResult[] = [];
for (const url of limitedUrls) {
const downloaded = await fetchBufferFromUrl(url);
const uploaded = await uploadBufferPairToStorage(downloaded.buffer, downloaded.mimeType, "enhance-pro");
const savedImage = await saveGeneratedImageRecord({
userId: params.userId,
chatId: params.chatId,
messageId: params.messageId,
prompt: finalPrompt,
originalUrl: uploaded.original,
thumbnailUrl: uploaded.thumbnail,
imageSize,
});
try {
const media = await upsertMediaAsset({
userId: params.userId,
chatId: params.chatId || null,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploaded.original,
previewUrl: uploaded.original,
thumbnailUrl: uploaded.thumbnail,
displayName: finalPrompt.slice(0, 80),
prompt: finalPrompt,
modelId: ENHANCE_PRO_MODEL_ID,
sourceMessageId: params.messageId || null,
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
imageSize,
provider: "krea-topaz-generative",
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: params.userId,
chatId: params.chatId || null,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
sourceMessageId: params.messageId || null,
metadata: {
prompt: finalPrompt,
},
});
}
}
} catch (assetError) {
console.error("[Enhance Pro] MediaAsset 注册失败:", assetError);
}
finalizedImages.push({
thumbnailUrl: uploaded.thumbnail,
originalUrl: uploaded.original,
width: params.width,
height: params.height,
});
}
const processingTime =
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined;
return {
success: true,
images: finalizedImages.map((image, index) => ({
index,
thumbnail_url: image.thumbnailUrl,
original_url: image.originalUrl,
width: image.width,
height: image.height,
})),
thumbnail_url: finalizedImages[0]?.thumbnailUrl || null,
original_url: finalizedImages[0]?.originalUrl || null,
processing_time: processingTime,
model_id: ENHANCE_PRO_MODEL_ID,
};
}
async function loadEnhanceProConfig() {
const setting = await getSystemSetting({ key: ENHANCE_PRO_KREA_SETTING_KEY });
const existingValue =
setting?.value && typeof setting.value === "object"
? (setting.value as Record<string, unknown>)
: {};
return normalizeEnhanceProKreaConfig(setting?.value, String(existingValue.apiKey || ""));
}
export const maxDuration = 180;
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json({ error: "缺少 taskId 参数" }, { status: 400 });
}
const config = await loadEnhanceProConfig();
if (!config.enabled) {
return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 });
}
if (!config.apiKey || !config.baseUrl) {
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
}
let taskResult: any;
try {
taskResult = await pollEnhanceProJob(config, taskId);
} catch (pollError) {
if (isEnhanceProTaskStillProcessing(pollError)) {
return buildEnhanceProTaskPendingResponse(taskId);
}
return NextResponse.json(
{
error: "图像增强 Pro 处理失败",
detail: pollError instanceof Error ? pollError.message : "轮询失败",
},
{ status: 500 },
);
}
const width = sanitizeDimension(searchParams.get("width"), 1024);
const height = sanitizeDimension(searchParams.get("height"), 1024);
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult,
prompt: searchParams.get("prompt") || "图像增强 Pro",
chatId: searchParams.get("chatId"),
messageId: searchParams.get("messageId"),
sourceImageUrls: searchParams.get("sourceImageUrl")
? [String(searchParams.get("sourceImageUrl"))]
: undefined,
width,
height,
});
return NextResponse.json(responsePayload);
} catch (error) {
console.error("[Enhance Pro API][TaskStatus] 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 },
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const config = await loadEnhanceProConfig();
if (!config.enabled) {
return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 });
}
if (!config.apiKey || !config.baseUrl) {
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
}
const body = (await request.json()) as EnhanceProRequestBody;
const prompt = String(body.prompt || "").trim();
const chatId = body.chatId || null;
const messageId = body.messageId || null;
const sourceInput = await resolveSourceInput(body);
const requestedWidth = sanitizeDimension(body.width, sourceInput.width);
const requestedHeight = sanitizeDimension(body.height, sourceInput.height);
const clampedDimensions = clampDimensions(
requestedWidth,
requestedHeight,
config.maxOutputEdge,
);
const batchSize = config.allowBatchSize
? clampInteger(body.batchSize, 1, config.maxBatchSize, 1)
: 1;
const startTime = Date.now();
const createdJob = await createEnhanceProJob({
config,
requestBody: {
...body,
batchSize,
},
sourceImageUrl: sourceInput.sourceImageUrl,
width: clampedDimensions.width,
height: clampedDimensions.height,
});
const createdStatus = normalizeJobStatus(createdJob?.status);
const taskId = String(createdJob?.job_id || "").trim();
if (!taskId) {
return NextResponse.json(
{ error: "创建图像增强 Pro 任务失败", detail: "Krea 未返回 job_id" },
{ status: 500 },
);
}
if (createdStatus === "completed") {
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult: createdJob,
prompt,
chatId,
messageId,
startedAt: startTime,
sourceImageUrls: [sourceInput.sourceImageUrl],
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json(responsePayload);
}
try {
const taskResult = await pollEnhanceProJob(config, taskId);
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult,
prompt,
chatId,
messageId,
startedAt: startTime,
sourceImageUrls: [sourceInput.sourceImageUrl],
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json(responsePayload);
} catch (pollError) {
if (isEnhanceProTaskStillProcessing(pollError)) {
return buildEnhanceProTaskPendingResponse(taskId);
}
return NextResponse.json(
{
error: "图像增强 Pro 处理失败",
detail: pollError instanceof Error ? pollError.message : "轮询失败",
},
{ status: 500 },
);
}
} catch (error) {
console.error("[Enhance Pro API] 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 },
);
}
}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
import useSWR from "swr";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
@ -24,7 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import { imageGenerationService, type EnhanceProImageResult } from "@/services/canvas";
import type { CanvasTextEditPayloadItem, NormalizedCropRect } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
@ -32,6 +33,7 @@ import { inferOriginalImageUrl } from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { EnhanceProDialog } from "@/components/enhance-pro-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
@ -80,6 +82,7 @@ interface BoxCutoutPoint {
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const imageNodeFetcher = (url: string) => fetch(url).then((res) => res.json());
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
@ -321,6 +324,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [enhanceProDialogOpen, setEnhanceProDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
@ -369,6 +373,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest);
const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest);
const allNodes = useCanvasStore((state) => state.nodes);
const { data: enhanceProFeatureConfig } = useSWR<{ config?: { enabled?: boolean } }>(
isSelected || enhanceProDialogOpen ? "/api/enhance/pro/config" : null,
imageNodeFetcher,
);
const isEnhanceProEnabled = enhanceProFeatureConfig?.config?.enabled === true;
// 🆕 物品拾取相关状态
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
@ -649,6 +658,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
if (!isSelected) {
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setEnhanceProDialogOpen(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
}
@ -661,11 +671,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}, [hasCompareSource, isThisNodePicking]);
useEffect(() => {
if ((isThisNodePicking || isCompareMode || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
if ((isThisNodePicking || isCompareMode || enhanceProDialogOpen || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
resetBoxCutoutState();
}
}, [
boxCutoutMode,
enhanceProDialogOpen,
isCompareMode,
isThisNodePicking,
notePanelOpen,
@ -804,6 +815,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
isMultiSelected ||
!data.src ||
isEditingTitle ||
enhanceProDialogOpen ||
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen ||
@ -846,6 +858,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}, [
boxCutoutMode,
data.src,
enhanceProDialogOpen,
isEditingTitle,
isMultiSelected,
isSelected,
@ -1939,6 +1952,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
'rmbg': '抠图',
'watermark': '去印',
'enhance': '增强',
'enhance-pro': '图像增强Pro',
'extend': '扩图',
'resize': '调尺',
'angle': '视角',
@ -1969,6 +1983,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setAngleDialogOpen(true);
return;
}
if (action === 'enhance-pro') {
if (!isEnhanceProEnabled) {
toast.error("图像增强 Pro 当前未开启");
return;
}
setEnhanceProDialogOpen(true);
return;
}
if (action === 'scene-fusion') {
setSceneFusionDialogOpen(true);
return;
@ -2159,6 +2181,42 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
});
};
const handleEnhanceProComplete = useCallback((images: EnhanceProImageResult[]) => {
const validImages = images.filter(
(item) => typeof item?.originalUrl === "string" && item.originalUrl.trim().length > 0,
);
if (validImages.length === 0) {
throw new Error("没有可添加到画布的图像增强 Pro 结果");
}
const maxColumns = validImages.length > 1 ? 2 : 1;
const gap = 20;
const baseX = node.position.x + node.size.width + gap;
const baseY = node.position.y;
validImages.forEach((image, index) => {
const column = index % maxColumns;
const row = Math.floor(index / maxColumns);
addNode({
type: "image",
position: {
x: baseX + column * (node.size.width + gap),
y: baseY + row * (node.size.height + gap),
},
size: { width: node.size.width, height: node.size.height },
data: {
src: image.originalUrl,
status: "completed",
prompt: "图像增强 Pro 结果",
title: `${displayTitle}-图像增强Pro-${index + 1}`,
originalSrc: data.src,
},
connections: [node.id],
});
});
}, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]);
const handleSceneFusionBatchAddToCanvas = useCallback((imageUrls: string[]) => {
const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0);
if (validUrls.length === 0) {
@ -2523,7 +2581,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
return (
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && !enhanceProDialogOpen && (
<div className="absolute -top-7 left-0 right-0 z-10 flex items-center justify-between px-1 text-xs text-[#2f7df6]">
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
@ -2880,6 +2938,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Sparkles className="h-4 w-4 mr-2" />
增强
</DropdownMenuItem>
{isEnhanceProEnabled && (
<DropdownMenuItem onClick={() => handleAction('enhance-pro')}>
<Sparkles className="h-4 w-4 mr-2" />
图像增强 Pro
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleAction('note')}>
<StickyNote className="h-4 w-4 mr-2" />
备注
@ -2974,6 +3038,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onComplete={handleAngleEditComplete}
/>
)}
{data.src && (
<EnhanceProDialog
open={enhanceProDialogOpen}
onOpenChange={setEnhanceProDialogOpen}
imageUrl={data.src}
onComplete={handleEnhanceProComplete}
/>
)}
<AgentExecutionSnapshotDialog
snapshotId={agentExecutionSnapshotId || null}
open={snapshotDialogOpen}
@ -3026,7 +3098,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && !boxCutoutMode && typeof document !== 'undefined' && createPortal(
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !enhanceProDialogOpen && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && !boxCutoutMode && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
ref={toolbarRef}
@ -3071,6 +3143,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
高清增强
</Button>
{isEnhanceProEnabled && (
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('enhance-pro')}
>
<Sparkles className="mr-1.5 h-4 w-4" />
图像增强 Pro
</Button>
)}
<Button
variant="ghost"
disabled={isProcessing}

View File

@ -0,0 +1,765 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import {
defaultEnhanceProPresets,
type EnhanceProKreaModel,
type EnhanceProOutputFormat,
type EnhanceProPreset,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { imageGenerationService, type EnhanceProImageResult } from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: number;
maxOutputEdge: number;
enableAdvancedControls: boolean;
presets: EnhanceProPreset[];
};
type FormState = {
prompt: string;
width: number;
height: number;
batchSize: number;
seed: number;
output_format: EnhanceProOutputFormat;
subject_detection: EnhanceProSubjectDetection;
face_enhancement: boolean;
face_enhancement_creativity: number;
face_enhancement_strength: number;
crop_to_fill: boolean;
model: EnhanceProKreaModel;
upscaling_activated: boolean;
image_scaling_factor: number;
creativity: number;
texture: number;
sharpen: number;
denoise: number;
detail: number;
};
const FALLBACK_CONFIG: EnhanceProClientConfig = {
enabled: false,
providerName: "Krea Topaz Generative",
baseUrl: "https://api.krea.ai",
defaultModel: "Redefine",
defaultOutputFormat: "jpg",
allowBatchSize: true,
maxBatchSize: 4,
maxOutputEdge: 4096,
enableAdvancedControls: true,
presets: defaultEnhanceProPresets(),
};
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
const maxEdge = Math.max(512, Math.round(maxOutputEdge || FALLBACK_CONFIG.maxOutputEdge));
const currentMax = Math.max(safeWidth, safeHeight);
if (currentMax <= maxEdge) {
return { width: safeWidth, height: safeHeight };
}
const scale = maxEdge / currentMax;
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
function createDefaultFormState(config: EnhanceProClientConfig, width: number, height: number): FormState {
const size = fitDimensions(width, height, config.maxOutputEdge);
return {
prompt: "",
width: size.width,
height: size.height,
batchSize: 1,
seed: 1337,
output_format: config.defaultOutputFormat,
subject_detection: "All",
face_enhancement: false,
face_enhancement_creativity: 0.5,
face_enhancement_strength: 0.5,
crop_to_fill: false,
model: config.defaultModel,
upscaling_activated: false,
image_scaling_factor: 1,
creativity: 3,
texture: 3,
sharpen: 0.5,
denoise: 0.5,
detail: 0.5,
};
}
function applyPresetToForm(
config: EnhanceProClientConfig,
width: number,
height: number,
preset?: EnhanceProPreset,
): FormState {
const baseState = createDefaultFormState(config, width, height);
if (!preset) {
return baseState;
}
return {
...baseState,
prompt: preset.prompt,
batchSize: config.allowBatchSize
? clampNumber(Math.round(preset.batchSize || 1), 1, config.maxBatchSize)
: 1,
seed: Number.isFinite(Number(preset.seed)) ? Math.round(Number(preset.seed)) : baseState.seed,
output_format: preset.outputFormat,
subject_detection: preset.subjectDetection,
face_enhancement: preset.faceEnhancement,
face_enhancement_creativity: clampNumber(preset.faceEnhancementCreativity, 0, 1),
face_enhancement_strength: clampNumber(preset.faceEnhancementStrength, 0, 1),
crop_to_fill: preset.cropToFill,
model: preset.model,
upscaling_activated: preset.upscalingActivated,
image_scaling_factor: clampNumber(preset.imageScalingFactor, 1, 32),
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
texture: clampNumber(Math.round(preset.texture), 1, 5),
sharpen: clampNumber(preset.sharpen, 0, 1),
denoise: clampNumber(preset.denoise, 0, 1),
detail: clampNumber(preset.detail, 0, 1),
};
}
function getSubjectDetectionLabel(subjectDetection: EnhanceProSubjectDetection) {
if (subjectDetection === "Foreground") {
return "前景";
}
if (subjectDetection === "Background") {
return "背景";
}
return "全部";
}
function buildImageLabel(index: number, total: number) {
return total > 1 ? `图像增强 Pro #${index + 1}` : "图像增强 Pro";
}
interface EnhanceProDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string;
onComplete?: (images: EnhanceProImageResult[]) => void;
}
export function EnhanceProDialog({
open,
onOpenChange,
imageUrl,
onComplete,
}: EnhanceProDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
const [selectedPresetId, setSelectedPresetId] = useState("");
const [formState, setFormState] = useState<FormState>(() =>
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024),
);
const { data: configPayload, isLoading } = useSWR<{ success?: boolean; config?: EnhanceProClientConfig }>(
open ? "/api/enhance/pro/config" : null,
fetcher,
);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config],
);
const presets = useMemo(
() => (config.presets.length ? config.presets : FALLBACK_CONFIG.presets),
[config.presets],
);
useEffect(() => {
if (!open || !imageUrl) 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 = imageUrl;
}, [open, imageUrl]);
useEffect(() => {
if (!open) return;
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, open, presets]);
const handleNumericField = <K extends keyof FormState>(key: K, nextValue: number) => {
setFormState((prev) => ({
...prev,
[key]: nextValue,
}));
};
const handleOpenChange = (nextOpen: boolean) => {
if (isSubmitting) return;
onOpenChange(nextOpen);
};
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;
}
setIsSubmitting(true);
try {
const result = await imageGenerationService.enhanceImagePro({
imageUrl,
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 失败");
}
onComplete?.(result.data.images);
toast.success(`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图像增强 Pro 失败");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
overlayClassName="bg-black"
className="sm:max-w-[840px] border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-950"
onInteractOutside={(event) => {
if (isSubmitting) event.preventDefault();
}}
onEscapeKeyDown={(event) => {
if (isSubmitting) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
图像增强 Pro
</DialogTitle>
<DialogDescription>
使用 {config.providerName} 对当前图片执行更可控的增强与重建。处理完成后会把结果直接添加到无限画布。
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] space-y-6 overflow-y-auto pr-1">
{isLoading ? (
<div className="flex items-center gap-2 rounded-lg border p-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载图像增强 Pro 配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
当前未启用图像增强 Pro。请前往系统设置的“本地AI设置”页签完成 Krea 配置后再使用。
</div>
) : null}
<div className="rounded-lg border bg-slate-50/60 p-4 text-sm text-muted-foreground dark:bg-slate-900/40">
原图尺寸:{naturalSize.width} x {naturalSize.height}
<span className="mx-2">|</span>
当前最大输出长边:{config.maxOutputEdge}px
</div>
{presets.length ? (
<div className="space-y-3">
<div>
<Label className="text-base">增强预设</Label>
<p className="mt-1 text-xs text-muted-foreground">
先选一个更贴近场景的预设,再按图片情况继续微调参数。
</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
{presets.map((preset) => {
const isSelected = preset.id === selectedPresetId;
return (
<button
key={preset.id}
type="button"
className={`rounded-xl border p-4 text-left transition ${
isSelected
? "border-sky-500 bg-sky-50 shadow-sm"
: "border-slate-200 bg-white hover:border-slate-300"
}`}
onClick={() => handleSelectPreset(preset.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-slate-900">{preset.name}</div>
<p className="text-xs text-muted-foreground">
{preset.description || "使用这一组默认参数快速开始增强。"}
</p>
</div>
{preset.isDefault ? (
<span className="rounded-full bg-slate-900 px-2 py-0.5 text-[11px] text-white">
默认
</span>
) : null}
</div>
<div className="mt-3 text-[11px] text-slate-600">
{preset.model} · {getSubjectDetectionLabel(preset.subjectDetection)}
{preset.faceEnhancement ? " · 人像增强" : ""}
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="enhance-pro-prompt">增强提示词</Label>
<Textarea
id="enhance-pro-prompt"
rows={3}
value={formState.prompt}
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持原图风格不变,提升细节层次,减少压缩噪点,让人物皮肤更自然。"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="enhance-pro-width">输出宽度</Label>
<Input
id="enhance-pro-width"
type="number"
min="1"
max={config.maxOutputEdge}
value={formState.width}
onChange={(event) =>
handleNumericField(
"width",
clampNumber(Number.parseInt(event.target.value, 10) || 1, 1, config.maxOutputEdge),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-height">输出高度</Label>
<Input
id="enhance-pro-height"
type="number"
min="1"
max={config.maxOutputEdge}
value={formState.height}
onChange={(event) =>
handleNumericField(
"height",
clampNumber(Number.parseInt(event.target.value, 10) || 1, 1, config.maxOutputEdge),
)
}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>增强模式</Label>
<Select
value={formState.model}
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Redefine">Redefine</SelectItem>
<SelectItem value="Recovery">Recovery</SelectItem>
<SelectItem value="Recovery V2">Recovery V2</SelectItem>
<SelectItem value="Reimagine">Reimagine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>输出格式</Label>
<Select
value={formState.output_format}
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
output_format: value as FormState["output_format"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="jpg">JPG</SelectItem>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="webp">WEBP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>作用区域</Label>
<Select
value={formState.subject_detection}
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
subject_detection: value as FormState["subject_detection"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">全部</SelectItem>
<SelectItem value="Foreground">前景</SelectItem>
<SelectItem value="Background">背景</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
{config.allowBatchSize ? (
<div className="space-y-2">
<Label>候选结果数</Label>
<Select
value={String(formState.batchSize)}
onValueChange={(value) =>
handleNumericField(
"batchSize",
clampNumber(Number.parseInt(value, 10) || 1, 1, config.maxBatchSize),
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: config.maxBatchSize }, (_, index) => index + 1).map((item) => (
<SelectItem key={item} value={String(item)}>
{item} 张
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="enhance-pro-seed">Seed</Label>
<Input
id="enhance-pro-seed"
type="number"
value={formState.seed}
onChange={(event) =>
handleNumericField("seed", Number.parseInt(event.target.value, 10) || 1337)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-image-scaling">放大倍数</Label>
<Input
id="enhance-pro-image-scaling"
type="number"
min="1"
max="32"
step="0.5"
value={formState.image_scaling_factor}
onChange={(event) =>
handleNumericField(
"image_scaling_factor",
clampNumber(Number.parseFloat(event.target.value) || 1, 1, 32),
)
}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm">人脸增强</Label>
<div className="text-xs text-muted-foreground">适合人像、半身图和证件风修复。</div>
</div>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, face_enhancement: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm">启用放大</Label>
<div className="text-xs text-muted-foreground">允许模型在增强时同时执行更强的上采样。</div>
</div>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, upscaling_activated: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm">裁剪铺满</Label>
<div className="text-xs text-muted-foreground">输出尺寸与原图比例不同时,裁切并铺满目标画布。</div>
</div>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
}
/>
</div>
</div>
{config.enableAdvancedControls ? (
<>
<Separator />
<div className="space-y-3">
<div>
<Label className="text-base">高级调节</Label>
<p className="mt-1 text-xs text-muted-foreground">
这些参数会直接映射到 Krea Topaz Generative,对结果风格与细节影响更明显。
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="enhance-pro-creativity">Creativity (1-6)</Label>
<Input
id="enhance-pro-creativity"
type="number"
min="1"
max="6"
value={formState.creativity}
onChange={(event) =>
handleNumericField(
"creativity",
clampNumber(Number.parseInt(event.target.value, 10) || 3, 1, 6),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-texture">Texture (1-5)</Label>
<Input
id="enhance-pro-texture"
type="number"
min="1"
max="5"
value={formState.texture}
onChange={(event) =>
handleNumericField(
"texture",
clampNumber(Number.parseInt(event.target.value, 10) || 3, 1, 5),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-detail">Detail (0-1)</Label>
<Input
id="enhance-pro-detail"
type="number"
min="0"
max="1"
step="0.1"
value={formState.detail}
onChange={(event) =>
handleNumericField(
"detail",
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-sharpen">Sharpen (0-1)</Label>
<Input
id="enhance-pro-sharpen"
type="number"
min="0"
max="1"
step="0.1"
value={formState.sharpen}
onChange={(event) =>
handleNumericField(
"sharpen",
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-denoise">Denoise (0-1)</Label>
<Input
id="enhance-pro-denoise"
type="number"
min="0"
max="1"
step="0.1"
value={formState.denoise}
onChange={(event) =>
handleNumericField(
"denoise",
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-face-strength">Face Strength (0-1)</Label>
<Input
id="enhance-pro-face-strength"
type="number"
min="0"
max="1"
step="0.1"
value={formState.face_enhancement_strength}
onChange={(event) =>
handleNumericField(
"face_enhancement_strength",
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhance-pro-face-creativity">Face Creativity (0-1)</Label>
<Input
id="enhance-pro-face-creativity"
type="number"
min="0"
max="1"
step="0.1"
value={formState.face_enhancement_creativity}
onChange={(event) =>
handleNumericField(
"face_enhancement_creativity",
clampNumber(Number.parseFloat(event.target.value) || 0, 0, 1),
)
}
/>
</div>
</div>
</div>
</>
) : null}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900">
<p>处理完成后,结果会保存到相册和存储桶,并作为新图片节点添加到当前画布。</p>
{config.allowBatchSize ? (
<p className="mt-1">
当前设置允许返回多张候选结果。画布中会按 {buildImageLabel(0, formState.batchSize)}
的方式依次创建新节点。
</p>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
取消
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || isLoading || !config.enabled}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
开始增强 Pro
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default EnhanceProDialog;

353
lib/enhance-pro/config.ts Normal file
View File

@ -0,0 +1,353 @@
export const ENHANCE_PRO_KREA_SETTING_KEY = "enhance_pro_krea_config";
export const ENHANCE_PRO_KREA_DEFAULT_BASE_URL = "https://api.krea.ai";
export const ENHANCE_PRO_KREA_DEFAULT_PROVIDER_NAME = "Krea Topaz Generative";
export const ENHANCE_PRO_KREA_DEFAULT_MODEL = "Redefine";
export const ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT = "jpg";
const TRAILING_SLASHES_REGEX = /\/+$/;
export type EnhanceProKreaModel =
| "Redefine"
| "Recovery"
| "Recovery V2"
| "Reimagine";
export type EnhanceProOutputFormat = "png" | "jpg" | "webp";
export type EnhanceProSubjectDetection = "All" | "Foreground" | "Background";
export type EnhanceProPreset = {
id: string;
name: string;
description: string;
isDefault: boolean;
prompt: string;
batchSize: number;
seed?: number;
outputFormat: EnhanceProOutputFormat;
subjectDetection: EnhanceProSubjectDetection;
faceEnhancement: boolean;
faceEnhancementCreativity: number;
faceEnhancementStrength: number;
cropToFill: boolean;
model: EnhanceProKreaModel;
upscalingActivated: boolean;
imageScalingFactor: number;
creativity: number;
texture: number;
sharpen: number;
denoise: number;
detail: number;
};
export type EnhanceProKreaConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
apiKey: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: number;
maxOutputEdge: number;
enableAdvancedControls: boolean;
presets: EnhanceProPreset[];
};
function clampInteger(
value: unknown,
min: number,
max: number,
fallback: number,
) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.min(max, Math.max(min, parsed));
}
function clampFloat(
value: unknown,
min: number,
max: number,
fallback: number,
) {
const parsed = Number.parseFloat(String(value ?? ""));
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.min(max, Math.max(min, parsed));
}
function createEnhanceProPresetId(name: string, index: number) {
const normalized = String(name || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || `enhance-pro-preset-${index + 1}`;
}
export function defaultEnhanceProPresets(): EnhanceProPreset[] {
return [
{
id: "fidelity-recovery",
name: "保真修复",
description: "优先保留原图结构和内容,适合老图修复、压缩损伤恢复、细节补全。",
isDefault: true,
prompt:
"保持原图主体、构图和光影关系基本不变,忠实修复细节,减少压缩噪点和模糊感,让整体画质更干净自然。",
batchSize: 1,
seed: 1337,
outputFormat: "jpg",
subjectDetection: "All",
faceEnhancement: false,
faceEnhancementCreativity: 0.35,
faceEnhancementStrength: 0.45,
cropToFill: false,
model: "Recovery V2",
upscalingActivated: true,
imageScalingFactor: 2,
creativity: 1,
texture: 2,
sharpen: 0.35,
denoise: 0.45,
detail: 0.65,
},
{
id: "portrait-repair",
name: "人像修复",
description: "针对人物和面部细节优化,适合证件照、半身照、商品模特图。",
isDefault: false,
prompt:
"重点增强人物面部和发丝细节,保持肤色自然真实,避免过度磨皮和塑料感,让五官更清晰但不过度重绘。",
batchSize: 1,
seed: 1337,
outputFormat: "jpg",
subjectDetection: "Foreground",
faceEnhancement: true,
faceEnhancementCreativity: 0.35,
faceEnhancementStrength: 0.75,
cropToFill: false,
model: "Recovery V2",
upscalingActivated: true,
imageScalingFactor: 2,
creativity: 2,
texture: 3,
sharpen: 0.4,
denoise: 0.4,
detail: 0.7,
},
{
id: "creative-reimagine",
name: "创意重建",
description: "允许模型更积极地重建高频细节,适合模糊图、概念图、二次质感提升。",
isDefault: false,
prompt:
"在保持主体可识别的基础上大胆重建高频细节与纹理,提升画面层次、材质感和视觉冲击力,让结果更有高级感。",
batchSize: 1,
seed: 1337,
outputFormat: "jpg",
subjectDetection: "All",
faceEnhancement: false,
faceEnhancementCreativity: 0.6,
faceEnhancementStrength: 0.55,
cropToFill: false,
model: "Reimagine",
upscalingActivated: true,
imageScalingFactor: 2,
creativity: 5,
texture: 4,
sharpen: 0.55,
denoise: 0.25,
detail: 0.8,
},
];
}
function normalizeEnhanceProPreset(
value: unknown,
index: number,
configDefaults: Pick<
EnhanceProKreaConfig,
"defaultModel" | "defaultOutputFormat" | "maxBatchSize"
>,
): EnhanceProPreset {
const defaults = defaultEnhanceProPresets()[index] || defaultEnhanceProPresets()[0];
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const model = String(raw.model || "").trim();
const outputFormat = String(
raw.outputFormat || raw.output_format || "",
).trim();
const subjectDetection = String(
raw.subjectDetection || raw.subject_detection || "",
).trim();
return {
id:
String(raw.id || "").trim() ||
createEnhanceProPresetId(String(raw.name || defaults.name), index),
name: String(raw.name || "").trim() || defaults.name,
description: String(raw.description || "").trim() || defaults.description,
isDefault: raw.isDefault === true,
prompt: String(raw.prompt || "").trim() || defaults.prompt,
batchSize: clampInteger(
raw.batchSize,
1,
configDefaults.maxBatchSize,
defaults.batchSize,
),
seed: Number.isFinite(Number(raw.seed))
? Math.round(Number(raw.seed))
: defaults.seed,
outputFormat:
outputFormat === "png" ||
outputFormat === "webp" ||
outputFormat === "jpg"
? outputFormat
: defaults.outputFormat || configDefaults.defaultOutputFormat,
subjectDetection:
subjectDetection === "Foreground" || subjectDetection === "Background"
? subjectDetection
: defaults.subjectDetection,
faceEnhancement:
typeof raw.faceEnhancement === "boolean"
? raw.faceEnhancement
: defaults.faceEnhancement,
faceEnhancementCreativity: clampFloat(
raw.faceEnhancementCreativity,
0,
1,
defaults.faceEnhancementCreativity,
),
faceEnhancementStrength: clampFloat(
raw.faceEnhancementStrength,
0,
1,
defaults.faceEnhancementStrength,
),
cropToFill:
typeof raw.cropToFill === "boolean" ? raw.cropToFill : defaults.cropToFill,
model:
model === "Recovery" ||
model === "Recovery V2" ||
model === "Reimagine" ||
model === "Redefine"
? model
: defaults.model || configDefaults.defaultModel,
upscalingActivated:
typeof raw.upscalingActivated === "boolean"
? raw.upscalingActivated
: defaults.upscalingActivated,
imageScalingFactor: clampFloat(
raw.imageScalingFactor,
1,
32,
defaults.imageScalingFactor,
),
creativity: clampInteger(raw.creativity, 1, 6, defaults.creativity),
texture: clampInteger(raw.texture, 1, 5, defaults.texture),
sharpen: clampFloat(raw.sharpen, 0, 1, defaults.sharpen),
denoise: clampFloat(raw.denoise, 0, 1, defaults.denoise),
detail: clampFloat(raw.detail, 0, 1, defaults.detail),
};
}
export function defaultEnhanceProKreaConfig(): EnhanceProKreaConfig {
return {
enabled: false,
providerName: ENHANCE_PRO_KREA_DEFAULT_PROVIDER_NAME,
baseUrl: ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
apiKey: "",
defaultModel: ENHANCE_PRO_KREA_DEFAULT_MODEL,
defaultOutputFormat: ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT,
allowBatchSize: true,
maxBatchSize: 4,
maxOutputEdge: 4096,
enableAdvancedControls: true,
presets: defaultEnhanceProPresets(),
};
}
export function normalizeEnhanceProKreaConfig(
value: unknown,
apiKeyFallback = ""
): EnhanceProKreaConfig {
const defaults = defaultEnhanceProKreaConfig();
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const defaultModel = String(raw.defaultModel || "").trim();
const defaultOutputFormat = String(raw.defaultOutputFormat || "").trim();
const normalizedPresets = Array.isArray(raw.presets)
? raw.presets.map((item, index) =>
normalizeEnhanceProPreset(item, index, {
defaultModel: defaults.defaultModel,
defaultOutputFormat: defaults.defaultOutputFormat,
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
}),
)
: defaultEnhanceProPresets();
const presetWithDefault = normalizedPresets.some((preset) => preset.isDefault)
? normalizedPresets
: normalizedPresets.map((preset, index) => ({
...preset,
isDefault: index === 0,
}));
return {
enabled: raw.enabled === true,
providerName:
String(raw.providerName || "").trim() || defaults.providerName,
baseUrl:
String(raw.baseUrl || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "") || defaults.baseUrl,
apiKey: String(raw.apiKey || "").trim() || apiKeyFallback,
defaultModel:
defaultModel === "Recovery" ||
defaultModel === "Recovery V2" ||
defaultModel === "Reimagine"
? defaultModel
: defaults.defaultModel,
defaultOutputFormat:
defaultOutputFormat === "png" ||
defaultOutputFormat === "webp" ||
defaultOutputFormat === "jpg"
? defaultOutputFormat
: defaults.defaultOutputFormat,
allowBatchSize: raw.allowBatchSize !== false,
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
maxOutputEdge: clampInteger(
raw.maxOutputEdge,
512,
16_384,
defaults.maxOutputEdge,
),
enableAdvancedControls: raw.enableAdvancedControls !== false,
presets: presetWithDefault,
};
}
export function sanitizeEnhanceProKreaConfigForClient(
config: EnhanceProKreaConfig,
) {
return {
enabled: config.enabled,
providerName: config.providerName,
baseUrl: config.baseUrl,
defaultModel: config.defaultModel,
defaultOutputFormat: config.defaultOutputFormat,
allowBatchSize: config.allowBatchSize,
maxBatchSize: config.maxBatchSize,
maxOutputEdge: config.maxOutputEdge,
enableAdvancedControls: config.enableAdvancedControls,
presets: config.presets,
};
}

View File

@ -1,5 +1,11 @@
// 画布服务层
import type { CanvasProject, CanvasState, CanvasNode } from '@/types/canvas';
import { requestLongTaskResult } from '@/lib/client/long-task';
import type {
EnhanceProKreaModel,
EnhanceProOutputFormat,
EnhanceProSubjectDetection,
} from '@/lib/enhance-pro/config';
// API 响应类型
interface ApiResponse<T = any> {
@ -28,6 +34,38 @@ export interface CanvasTextEditPayloadItem {
action?: 'replace' | 'delete';
}
export interface EnhanceProImageResult {
originalUrl: string;
thumbnailUrl: string;
width?: number;
height?: number;
}
export interface EnhanceProRequestParams {
imageUrl: string;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
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;
}
// 画布服务
export const canvasService = {
// 获取画布列表
@ -289,6 +327,72 @@ export const imageGenerationService = {
}
},
async enhanceImagePro(
params: EnhanceProRequestParams,
): Promise<ApiResponse<{ images: EnhanceProImageResult[] }>> {
try {
const payload = await requestLongTaskResult({
endpoint: '/api/enhance/pro',
payload: params,
pollingParams: {
prompt: params.prompt || '图像增强 Pro',
chatId: params.chatId,
messageId: params.messageId,
sourceImageUrl: params.imageUrl,
width: params.width,
height: params.height,
},
});
if (!payload?.success) {
return { success: false, error: payload?.error || payload?.detail || '图像增强 Pro 失败' };
}
const images: EnhanceProImageResult[] = [];
if (Array.isArray(payload.images)) {
for (const item of payload.images) {
const originalUrl = String(item?.original_url || '').trim();
const thumbnailUrl = String(item?.thumbnail_url || item?.original_url || '').trim();
if (!originalUrl) {
continue;
}
images.push({
originalUrl,
thumbnailUrl,
width: typeof item?.width === 'number' ? item.width : undefined,
height: typeof item?.height === 'number' ? item.height : undefined,
});
}
}
if (images.length === 0 && payload.original_url) {
images.push({
originalUrl: payload.original_url,
thumbnailUrl: payload.thumbnail_url || payload.original_url,
width: typeof payload.width === 'number' ? payload.width : undefined,
height: typeof payload.height === 'number' ? payload.height : undefined,
});
}
if (images.length === 0) {
return { success: false, error: '图像增强 Pro 未返回有效结果' };
}
return {
success: true,
data: {
images,
},
};
} catch (error) {
console.error('[Canvas] 图像增强 Pro 失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '图像增强 Pro 失败',
};
}
},
// 图片扩展 - 使用图片URL
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> {
try {

View File

@ -175,7 +175,7 @@ export interface Attachment {
// 图片编辑操作
export interface ImageEditAction {
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout' | 'layer-split';
type: 'rmbg' | 'enhance' | 'enhance-pro' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout' | 'layer-split';
timestamp: Date;
params?: Record<string, any>;
resultUrl?: string;