Your Name 670a50c4d0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add enhance pro canvas workflow and presets
2026-04-04 22:40:50 +08:00

661 lines
20 KiB
TypeScript

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 },
);
}
}