feat: add enhance pro canvas workflow and presets
This commit is contained in:
parent
d76b31952f
commit
670a50c4d0
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
||||
28
app/api/enhance/pro/config/route.ts
Normal file
28
app/api/enhance/pro/config/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
660
app/api/enhance/pro/route.ts
Normal file
660
app/api/enhance/pro/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
765
components/enhance-pro-dialog.tsx
Normal file
765
components/enhance-pro-dialog.tsx
Normal 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
353
lib/enhance-pro/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user