789 lines
24 KiB
TypeScript
789 lines
24 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
consumeUserPoints,
|
|
createMediaAssetRelation,
|
|
getPointsSystemConfig,
|
|
getMediaAssetsByUserAndUrls,
|
|
getSystemSetting,
|
|
getUserById,
|
|
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 { calculateEnhanceProPointsEstimate } from "@/lib/points/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;
|
|
let enhanceProKeyPoolCursor = 0;
|
|
|
|
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 getEnhanceProKeyFingerprint(apiKey: string) {
|
|
return createHash("sha256")
|
|
.update(String(apiKey || "").trim())
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
}
|
|
|
|
function getEnhanceProEffectiveApiKeys(config: EnhanceProKreaConfig) {
|
|
const pooledKeys = Array.isArray(config.apiKeys) ? config.apiKeys : [];
|
|
if (pooledKeys.length > 0) return pooledKeys;
|
|
return config.apiKey ? [config.apiKey] : [];
|
|
}
|
|
|
|
function resolveEnhanceProApiKey(
|
|
config: EnhanceProKreaConfig,
|
|
preferredKeyId?: string | null,
|
|
) {
|
|
const keys = getEnhanceProEffectiveApiKeys(config);
|
|
if (keys.length === 0) return null;
|
|
|
|
const normalizedPreferredKeyId = String(preferredKeyId || "").trim();
|
|
if (normalizedPreferredKeyId) {
|
|
const matchedKey = keys.find(
|
|
(apiKey) => getEnhanceProKeyFingerprint(apiKey) === normalizedPreferredKeyId,
|
|
);
|
|
if (matchedKey) {
|
|
return {
|
|
apiKey: matchedKey,
|
|
keyId: normalizedPreferredKeyId,
|
|
};
|
|
}
|
|
}
|
|
|
|
const selectedKey = keys[enhanceProKeyPoolCursor % keys.length];
|
|
enhanceProKeyPoolCursor = (enhanceProKeyPoolCursor + 1) % keys.length;
|
|
return {
|
|
apiKey: selectedKey,
|
|
keyId: getEnhanceProKeyFingerprint(selectedKey),
|
|
};
|
|
}
|
|
|
|
function buildEnhanceProTaskPendingResponse(taskId: string, taskKeyId?: string | null) {
|
|
return NextResponse.json(
|
|
{
|
|
success: true,
|
|
processing: true,
|
|
status: "running",
|
|
taskId,
|
|
taskKeyId,
|
|
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,
|
|
params.config.maxUpscaleFactor,
|
|
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 });
|
|
}
|
|
const keySelection = resolveEnhanceProApiKey(config, searchParams.get("taskKeyId"));
|
|
if (!keySelection || !config.baseUrl) {
|
|
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
|
|
}
|
|
|
|
let taskResult: any;
|
|
try {
|
|
taskResult = await pollEnhanceProJob(
|
|
{ ...config, apiKey: keySelection.apiKey },
|
|
taskId
|
|
);
|
|
} catch (pollError) {
|
|
if (isEnhanceProTaskStillProcessing(pollError)) {
|
|
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
|
|
}
|
|
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 });
|
|
}
|
|
const keySelection = resolveEnhanceProApiKey(config);
|
|
if (!keySelection || !config.baseUrl) {
|
|
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
|
|
}
|
|
const requestConfig = { ...config, apiKey: keySelection.apiKey };
|
|
|
|
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 selectedModel: EnhanceProKreaModel =
|
|
body.model === "Recovery" ||
|
|
body.model === "Recovery V2" ||
|
|
body.model === "Reimagine" ||
|
|
body.model === "Redefine"
|
|
? body.model
|
|
: config.defaultModel;
|
|
const upscalingActivated = body.upscaling_activated === true;
|
|
const imageScalingFactor = clampNumber(
|
|
body.image_scaling_factor,
|
|
1,
|
|
config.maxUpscaleFactor,
|
|
1,
|
|
);
|
|
const pointsConfig = await getPointsSystemConfig();
|
|
const pointsEstimate = calculateEnhanceProPointsEstimate(pointsConfig, {
|
|
width: clampedDimensions.width,
|
|
height: clampedDimensions.height,
|
|
batchSize,
|
|
model: selectedModel,
|
|
upscalingActivated,
|
|
scaleFactor: imageScalingFactor,
|
|
});
|
|
|
|
if (pointsEstimate.chargeable && pointsConfig.blockOnInsufficient !== false) {
|
|
const userData = await getUserById({ id: session.user.id });
|
|
if (!userData?.isHighVip) {
|
|
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
|
|
if (currentBalance < pointsEstimate.cost) {
|
|
const message = `积分不足,当前余额 ${currentBalance},本次预计消耗 ${pointsEstimate.cost}。`;
|
|
return NextResponse.json(
|
|
{
|
|
error: message,
|
|
code: "insufficient_points",
|
|
message,
|
|
balance: currentBalance,
|
|
expectedCost: pointsEstimate.cost,
|
|
unitCost: pointsEstimate.cost,
|
|
},
|
|
{ status: 402 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const createdJob = await createEnhanceProJob({
|
|
config: requestConfig,
|
|
requestBody: {
|
|
...body,
|
|
batchSize,
|
|
model: selectedModel,
|
|
upscaling_activated: upscalingActivated,
|
|
image_scaling_factor: imageScalingFactor,
|
|
},
|
|
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 (pointsEstimate.chargeable) {
|
|
try {
|
|
await consumeUserPoints({
|
|
userId: session.user.id,
|
|
amount: pointsEstimate.cost,
|
|
reason: "enhance_pro_generation",
|
|
modelId: `${ENHANCE_PRO_MODEL_ID}:${selectedModel}`,
|
|
chatId,
|
|
messageId,
|
|
note: "图像增强 Pro 消耗积分",
|
|
});
|
|
} catch (pointsError) {
|
|
console.error("[Points] Failed to consume Enhance Pro points:", pointsError);
|
|
}
|
|
}
|
|
|
|
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,
|
|
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const taskResult = await pollEnhanceProJob(requestConfig, 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,
|
|
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
|
|
});
|
|
} catch (pollError) {
|
|
if (isEnhanceProTaskStillProcessing(pollError)) {
|
|
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
}
|