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,
|
normalizeThirdPartySqlServerConfig,
|
||||||
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
||||||
} from "@/lib/sqlserver/config";
|
} from "@/lib/sqlserver/config";
|
||||||
|
import {
|
||||||
|
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||||
|
normalizeEnhanceProKreaConfig,
|
||||||
|
} from "@/lib/enhance-pro/config";
|
||||||
|
|
||||||
const settingSchema = z.object({
|
const settingSchema = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
@ -223,6 +227,31 @@ export async function POST(request: Request) {
|
|||||||
nextValue = normalizeProductPlanningAiModelConfig(value);
|
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({
|
const setting = await upsertSystemSetting({
|
||||||
key,
|
key,
|
||||||
value: nextValue,
|
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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
import { createPortal } from "react-dom";
|
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 { 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";
|
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 { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
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 { CanvasTextEditPayloadItem, NormalizedCropRect } from "@/services/canvas";
|
||||||
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
|
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -32,6 +33,7 @@ import { inferOriginalImageUrl } from "@/lib/image-url";
|
|||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
||||||
|
import { EnhanceProDialog } from "@/components/enhance-pro-dialog";
|
||||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||||||
@ -80,6 +82,7 @@ interface BoxCutoutPoint {
|
|||||||
|
|
||||||
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
|
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
|
||||||
const BOX_CUTOUT_MIN_EDGE_PX = 24;
|
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) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
Math.min(max, Math.max(min, value));
|
Math.min(max, Math.max(min, value));
|
||||||
@ -321,6 +324,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
||||||
|
const [enhanceProDialogOpen, setEnhanceProDialogOpen] = useState(false);
|
||||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||||
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
|
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
|
||||||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||||
@ -369,6 +373,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest);
|
const imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest);
|
||||||
const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest);
|
const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest);
|
||||||
const allNodes = useCanvasStore((state) => state.nodes);
|
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);
|
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||||||
@ -649,6 +658,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
setTextEditPanelOpen(false);
|
setTextEditPanelOpen(false);
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
|
setEnhanceProDialogOpen(false);
|
||||||
setPromptDialogOpen(false);
|
setPromptDialogOpen(false);
|
||||||
resetBoxCutoutState();
|
resetBoxCutoutState();
|
||||||
}
|
}
|
||||||
@ -661,11 +671,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
}, [hasCompareSource, isThisNodePicking]);
|
}, [hasCompareSource, isThisNodePicking]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((isThisNodePicking || isCompareMode || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
|
if ((isThisNodePicking || isCompareMode || enhanceProDialogOpen || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
|
||||||
resetBoxCutoutState();
|
resetBoxCutoutState();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
boxCutoutMode,
|
boxCutoutMode,
|
||||||
|
enhanceProDialogOpen,
|
||||||
isCompareMode,
|
isCompareMode,
|
||||||
isThisNodePicking,
|
isThisNodePicking,
|
||||||
notePanelOpen,
|
notePanelOpen,
|
||||||
@ -804,6 +815,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
isMultiSelected ||
|
isMultiSelected ||
|
||||||
!data.src ||
|
!data.src ||
|
||||||
isEditingTitle ||
|
isEditingTitle ||
|
||||||
|
enhanceProDialogOpen ||
|
||||||
sceneFusionDialogOpen ||
|
sceneFusionDialogOpen ||
|
||||||
promptDialogOpen ||
|
promptDialogOpen ||
|
||||||
notePanelOpen ||
|
notePanelOpen ||
|
||||||
@ -846,6 +858,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
}, [
|
}, [
|
||||||
boxCutoutMode,
|
boxCutoutMode,
|
||||||
data.src,
|
data.src,
|
||||||
|
enhanceProDialogOpen,
|
||||||
isEditingTitle,
|
isEditingTitle,
|
||||||
isMultiSelected,
|
isMultiSelected,
|
||||||
isSelected,
|
isSelected,
|
||||||
@ -1939,6 +1952,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
'rmbg': '抠图',
|
'rmbg': '抠图',
|
||||||
'watermark': '去印',
|
'watermark': '去印',
|
||||||
'enhance': '增强',
|
'enhance': '增强',
|
||||||
|
'enhance-pro': '图像增强Pro',
|
||||||
'extend': '扩图',
|
'extend': '扩图',
|
||||||
'resize': '调尺',
|
'resize': '调尺',
|
||||||
'angle': '视角',
|
'angle': '视角',
|
||||||
@ -1969,6 +1983,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
setAngleDialogOpen(true);
|
setAngleDialogOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'enhance-pro') {
|
||||||
|
if (!isEnhanceProEnabled) {
|
||||||
|
toast.error("图像增强 Pro 当前未开启");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnhanceProDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'scene-fusion') {
|
if (action === 'scene-fusion') {
|
||||||
setSceneFusionDialogOpen(true);
|
setSceneFusionDialogOpen(true);
|
||||||
return;
|
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 handleSceneFusionBatchAddToCanvas = useCallback((imageUrls: string[]) => {
|
||||||
const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0);
|
const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0);
|
||||||
if (validUrls.length === 0) {
|
if (validUrls.length === 0) {
|
||||||
@ -2523,7 +2581,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
return (
|
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="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">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<ImagePlus className="h-3 w-3" />
|
<ImagePlus className="h-3 w-3" />
|
||||||
@ -2880,6 +2938,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<Sparkles className="h-4 w-4 mr-2" />
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
增强
|
增强
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isEnhanceProEnabled && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction('enhance-pro')}>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
图像增强 Pro
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={() => handleAction('note')}>
|
<DropdownMenuItem onClick={() => handleAction('note')}>
|
||||||
<StickyNote className="h-4 w-4 mr-2" />
|
<StickyNote className="h-4 w-4 mr-2" />
|
||||||
备注
|
备注
|
||||||
@ -2974,6 +3038,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
onComplete={handleAngleEditComplete}
|
onComplete={handleAngleEditComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{data.src && (
|
||||||
|
<EnhanceProDialog
|
||||||
|
open={enhanceProDialogOpen}
|
||||||
|
onOpenChange={setEnhanceProDialogOpen}
|
||||||
|
imageUrl={data.src}
|
||||||
|
onComplete={handleEnhanceProComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<AgentExecutionSnapshotDialog
|
<AgentExecutionSnapshotDialog
|
||||||
snapshotId={agentExecutionSnapshotId || null}
|
snapshotId={agentExecutionSnapshotId || null}
|
||||||
open={snapshotDialogOpen}
|
open={snapshotDialogOpen}
|
||||||
@ -3026,7 +3098,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
{/* 工具栏使用 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}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div
|
<div
|
||||||
ref={toolbarRef}
|
ref={toolbarRef}
|
||||||
@ -3071,6 +3143,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
高清增强
|
高清增强
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={isProcessing}
|
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 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 响应类型
|
// API 响应类型
|
||||||
interface ApiResponse<T = any> {
|
interface ApiResponse<T = any> {
|
||||||
@ -28,6 +34,38 @@ export interface CanvasTextEditPayloadItem {
|
|||||||
action?: 'replace' | 'delete';
|
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 = {
|
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
|
// 图片扩展 - 使用图片URL
|
||||||
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> {
|
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -175,7 +175,7 @@ export interface Attachment {
|
|||||||
|
|
||||||
// 图片编辑操作
|
// 图片编辑操作
|
||||||
export interface ImageEditAction {
|
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;
|
timestamp: Date;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user