ai-chatbot/app/api/rmbg/route.ts
Your Name 12583cdd10
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: sync latest canvas and media updates
2026-03-06 17:31:32 +08:00

768 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
type RmbgProvider = "rmbg" | "photoroom" | "aoscdn";
/**
* 压缩图片生成缩略图(使用 sharp)
*/
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 80 }) // PNG格式保持透明背景
.toBuffer();
console.log(`[RMBG] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[RMBG] Failed to create thumbnail:", error);
// 如果失败,返回原图
return buffer;
}
}
/**
* 将图片 buffer 上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBufferToS3(buffer: Buffer, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
try {
// 生成文件名
const ext = mimeType.split("/")[1] || "png";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `rmbg-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `rmbg-orig-${timestamp}-${randomStr}.${ext}`;
console.log(`[RMBG] Original size: ${buffer.length} bytes`);
// 生成缩略图
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
// 转换为 ArrayBuffer
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 并行上传缩略图和原图到 S3
console.log(`[RMBG] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[RMBG] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[RMBG] Failed to upload to S3:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
const buffer = Buffer.from(base64Data, "base64");
return uploadBufferToS3(buffer, mimeType);
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const AOS_MAX_RESOLUTION = 4096;
const AOS_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
const withTimeout = async <T,>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timeoutId);
}
};
function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) {
const [, width, height] = resolutionMatch;
return `图片分辨率超限(${width}x${height}),AOS要求宽高都不超过 ${AOS_MAX_RESOLUTION}px。请缩小后重试。`;
}
if (/invalid file/i.test(detail)) {
return "图片文件无法识别,请尝试转换为 JPG/PNG 后重试。";
}
return null;
}
async function prepareImageForAos(
imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
try {
const sharp = await import("sharp");
const image = sharp.default(imageBuffer);
const metadata = await image.metadata();
const width = metadata.width;
const height = metadata.height;
const format = (metadata.format || "png").toLowerCase();
const ext = format === "jpeg" ? "jpg" : format;
const filenameExt =
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
const mimeType =
ext === "jpg" ? "image/jpeg" :
ext === "webp" ? "image/webp" :
ext === "tiff" ? "image/tiff" :
"image/png";
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
return {
buffer: imageBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
}
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
const resizedMeta = await sharp.default(resizedBuffer).metadata();
console.warn(
`[AOS RMBG] 输入图片分辨率超限,已自动缩放: ${width}x${height} -> ${resizedMeta.width}x${resizedMeta.height}`
);
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
} catch (error) {
console.error("[AOS RMBG] 图片预处理失败,继续使用原图:", error);
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
}
}
/**
* 使用 RMBG-2.0 API 进行抠图
*/
async function processWithRMBG(
imageBase64: string,
config: { apiUrl: string; apiKey?: string }
): Promise<{ success: boolean; image_base64?: string; processing_time?: number; error?: string }> {
const apiUrl = config.apiUrl.endsWith("/")
? `${config.apiUrl}rmbg/base64`
: `${config.apiUrl}/rmbg/base64`;
const requestBody = {
image_base64: imageBase64,
};
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (config.apiKey) {
headers["X-API-Key"] = config.apiKey;
}
const startTime = Date.now();
// 添加超时控制(30秒)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
const processingTime = (Date.now() - startTime) / 1000;
if (!response.ok) {
const errorText = await response.text();
console.error("[RMBG API] 请求失败:", { status: response.status, error: errorText });
return { success: false, error: errorText || response.statusText };
}
const result = await response.json();
if (!result.success || !result.image_base64) {
return { success: false, error: result.detail || "未知错误" };
}
return {
success: true,
image_base64: result.image_base64,
processing_time: result.processing_time || processingTime,
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: "请求超时(30秒)" };
}
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
}
}
/**
* 使用 PhotoRoom API 进行抠图
*/
async function processWithPhotoRoom(
imageBuffer: Buffer,
apiKey: string
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now();
try {
// 构建 FormData
const formData = new FormData();
// 将 Buffer 转换为 Uint8Array 以避免类型问题
const uint8Array = new Uint8Array(imageBuffer);
const blob = new Blob([uint8Array], { type: "image/png" });
formData.append("imageFile", blob, "image.png");
formData.append("referenceBox", "originalImage");
console.log("[PhotoRoom] Sending request to API...");
// 添加超时控制(60秒,PhotoRoom可能需要更长时间)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
const response = await fetch("https://image-api.photoroom.com/v2/edit", {
method: "POST",
headers: {
"x-api-key": apiKey,
"pr-hd-background-removal": "auto",
},
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const processingTime = (Date.now() - startTime) / 1000;
console.log("[PhotoRoom] Response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error("[PhotoRoom API] 请求失败:", { status: response.status, error: errorText });
return { success: false, error: `PhotoRoom API错误: ${response.status} - ${errorText}` };
}
// PhotoRoom 直接返回图片二进制数据
const resultBuffer = Buffer.from(await response.arrayBuffer());
console.log("[PhotoRoom] Received image buffer, size:", resultBuffer.length, "bytes");
return {
success: true,
imageBuffer: resultBuffer,
processing_time: processingTime,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: "PhotoRoom请求超时(60秒)" };
}
console.error("[PhotoRoom] Error:", error);
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
}
}
/**
* 使用 AOS segmentation API 进行抠图(异步任务 + 轮询)
*/
async function processWithAosSegmentation(
imageBuffer: Buffer,
config: {
apiKey: string;
apiBaseUrl?: string;
foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1;
format?: "png" | "jpg";
}
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now();
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
const headers = { "X-API-KEY": config.apiKey };
try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData();
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { type: normalizedImage.mimeType });
createForm.append("image_file", imageBlob, normalizedImage.filename);
createForm.append("sync", "0");
createForm.append("return_type", "1");
createForm.append("output_type", "2");
createForm.append("format", config.format || "png");
createForm.append("crop", config.crop === 1 ? "1" : "0");
if (config.foregroundType) {
createForm.append("type", config.foregroundType);
}
console.log("[AOS RMBG] creating segmentation task...");
const createResponse = await withTimeout(
(signal) =>
fetch(createUrl, {
method: "POST",
headers,
body: createForm,
signal,
}),
AOS_CREATE_TIMEOUT_MS
);
const createPayload = await createResponse.json().catch(() => null);
if (!createResponse.ok || !createPayload?.data?.task_id) {
return {
success: false,
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
};
}
const taskId = createPayload.data.task_id as string;
const taskUrl = `${createUrl}/${taskId}`;
const maxPollCount = 60;
for (let pollCount = 0; pollCount < maxPollCount; pollCount++) {
await sleep(1000);
const pollResponse = await withTimeout(
(signal) =>
fetch(taskUrl, {
method: "GET",
headers,
signal,
}),
AOS_POLL_TIMEOUT_MS
);
const pollPayload = await pollResponse.json().catch(() => null);
if (!pollResponse.ok || !pollPayload?.data) {
return {
success: false,
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
};
}
const state = Number(pollPayload.data.state);
const imageUrl = pollPayload.data.image as string | undefined;
const stateDetail = String(pollPayload.data.state_detail || "");
const errMessage = String(pollPayload.data.err_message || pollPayload.data.err_info || "");
if (pollCount === 0 || pollCount % 5 === 0) {
console.log(`[AOS RMBG] poll=${pollCount + 1} state=${state} detail=${stateDetail}`);
}
if (state === 1 && imageUrl) {
const imageResponse = await withTimeout(
(signal) =>
fetch(imageUrl, {
method: "GET",
signal,
}),
AOS_DOWNLOAD_TIMEOUT_MS
);
if (!imageResponse.ok) {
return {
success: false,
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
};
}
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
return {
success: true,
imageBuffer: resultBuffer,
processing_time: (Date.now() - startTime) / 1000,
};
}
// AOS: state=2(Preparing) 属于处理中,不能当失败;只有负数状态视为失败
if (state < 0) {
return {
success: false,
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
};
}
}
return { success: false, error: "AOS任务超时(最多60秒)" };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { success: false, error: "AOS请求超时,请稍后重试" };
}
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
}
}
/**
* 抠图API路由
* 从数据库读取API配置,支持 RMBG-2.0、PhotoRoom、AOS 三种服务
*/
export async function POST(request: NextRequest) {
try {
// 0. 获取用户会话
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
}
const userId = session.user.id;
// 1. 从数据库获取抠图API配置
const rmbgConfig = await getSystemSetting({ key: "rmbg_api_config" });
if (!rmbgConfig || !rmbgConfig.value) {
return NextResponse.json(
{ error: "抠图API配置未设置,请在系统设置中配置" },
{ status: 400 }
);
}
const config = rmbgConfig.value as {
provider?: RmbgProvider;
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
aosApiKey?: string;
aosApiBaseUrl?: string;
aosForegroundType?: "" | "person" | "object" | "stamp";
aosCrop?: 0 | 1;
aosFormat?: "png" | "jpg";
};
const provider: RmbgProvider = config.provider || "rmbg";
// 验证配置
if (provider === "rmbg" && !config.apiUrl) {
return NextResponse.json(
{ error: "RMBG API地址未配置" },
{ status: 400 }
);
}
if (provider === "photoroom" && !config.photoroomApiKey) {
return NextResponse.json(
{ error: "PhotoRoom API密钥未配置" },
{ status: 400 }
);
}
if (provider === "aoscdn" && !config.aosApiKey) {
return NextResponse.json(
{ error: "AOS API密钥未配置" },
{ status: 400 }
);
}
// 2. 获取请求体
const contentType = request.headers.get("content-type") || "";
let imageBuffer: Buffer;
let imageBase64: string;
let prompt = "";
let chatId: string | null = null;
let messageId: string | null = null;
let skipGallerySave = false;
let sourceImageUrl: string | null = null;
if (contentType.includes("application/json")) {
const body = await request.json();
prompt = body.prompt || "抠图处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
const imageUrl = body.imageUrl || body.image_url;
if (imageUrl) {
sourceImageUrl = String(imageUrl);
console.log(`[RMBG] 从 URL 获取图片: ${imageUrl}`);
try {
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
return NextResponse.json(
{ error: `无法获取图片: ${imageResponse.status} ${imageResponse.statusText}` },
{ status: 400 }
);
}
const arrayBuffer = await imageResponse.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
console.log(`[RMBG] 图片已获取,大小: ${imageBuffer.length} bytes`);
} catch (fetchError) {
console.error("[RMBG] 获取图片失败:", fetchError);
return NextResponse.json(
{ error: "无法获取图片,请检查图片链接是否有效" },
{ status: 400 }
);
}
} else if (body.image_base64) {
imageBase64 = body.image_base64;
imageBuffer = Buffer.from(imageBase64, "base64");
} else {
return NextResponse.json(
{ error: "缺少 imageUrl 或 image_base64 参数" },
{ status: 400 }
);
}
} else if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "缺少file参数" },
{ status: 400 }
);
}
const arrayBuffer = await file.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
prompt = (formData.get("prompt") as string) || "抠图处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
} else {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 }
);
}
// 3. 根据提供商调用不同的API
console.log(`[RMBG] Using provider: ${provider}`);
let resultBuffer: Buffer;
let resultBase64: string;
let processingTime: number;
if (provider === "photoroom") {
// 使用 PhotoRoom API
const result = await processWithPhotoRoom(imageBuffer, config.photoroomApiKey!);
if (!result.success || !result.imageBuffer) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.error },
{ status: 500 }
);
}
resultBuffer = result.imageBuffer;
resultBase64 = resultBuffer.toString("base64");
processingTime = result.processing_time || 0;
} else if (provider === "aoscdn") {
const result = await processWithAosSegmentation(imageBuffer, {
apiKey: config.aosApiKey!,
apiBaseUrl: config.aosApiBaseUrl,
foregroundType: config.aosForegroundType || "",
crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png",
});
if (!result.success || !result.imageBuffer) {
const detail = result.error || "AOS处理失败";
const friendlyMessage = getAosFriendlyErrorMessage(detail);
return NextResponse.json(
{ error: "抠图处理失败", detail: friendlyMessage || detail },
{ status: friendlyMessage ? 400 : 500 }
);
}
resultBuffer = result.imageBuffer;
resultBase64 = resultBuffer.toString("base64");
processingTime = result.processing_time || 0;
} else {
// 使用 RMBG-2.0 API
const result = await processWithRMBG(imageBase64, {
apiUrl: config.apiUrl!,
apiKey: config.apiKey,
});
if (!result.success || !result.image_base64) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.error },
{ status: 500 }
);
}
resultBase64 = result.image_base64;
resultBuffer = Buffer.from(resultBase64, "base64");
processingTime = result.processing_time || 0;
}
// 4. 上传处理后的图片到S3
let thumbnailUrl: string;
let originalUrl: string;
try {
const uploadResult = await uploadBufferToS3(resultBuffer, "image/png");
thumbnailUrl = uploadResult.thumbnail;
originalUrl = uploadResult.original;
console.log(`[RMBG] ✅ Images uploaded to S3 successfully`);
} catch (uploadError) {
console.error("[RMBG] ❌ Failed to upload to S3:", uploadError);
return NextResponse.json(
{
error: "图片上传到存储桶失败",
detail: uploadError instanceof Error ? uploadError.message : "未知错误"
},
{ status: 500 }
);
}
// 5. 保存图片记录到数据库(可选)
if (!skipGallerySave) {
let saved = false;
let saveError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
([savedImage] = await db.insert(generatedImage).values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: "2K",
prompt: prompt || "抠图处理",
modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0",
}).returning());
console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`);
saved = true;
break;
} catch (dbError) {
saveError = dbError;
if (attempt < 3) {
await sleep(120 * attempt);
}
}
}
if (!saved) {
console.error("[RMBG] ❌ 保存到数据库失败:", saveError);
throw new Error("图片处理成功但保存到相册失败,请稍后重试");
}
if (savedImage) {
try {
const sourceAssets =
sourceImageUrl
? await getMediaAssetsByUserAndUrls({
userId,
urls: [sourceImageUrl],
})
: [];
const modelId =
provider === "photoroom"
? "photoroom-hd"
: provider === "aoscdn"
? "aos-segmentation"
: "rmbg-2.0";
const media = await upsertMediaAsset({
userId,
chatId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl,
previewUrl: originalUrl,
thumbnailUrl,
displayName: (prompt || "抠图处理").slice(0, 80),
prompt: prompt || "抠图处理",
modelId,
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
provider,
imageSize: "2K",
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId,
chatId,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
sourceMessageId: messageId,
metadata: {
prompt: prompt || "抠图处理",
provider,
},
});
}
}
} catch (assetError) {
console.error("[RMBG] ⚠️ MediaAsset 注册失败:", assetError);
}
}
} else {
console.log("[RMBG] Skip gallery save by request flag");
}
// 6. 返回结果
return NextResponse.json({
success: true,
image_base64: resultBase64,
thumbnail_url: thumbnailUrl,
original_url: originalUrl,
processing_time: processingTime,
provider: provider,
});
} catch (error) {
console.error("[RMBG API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
);
}
}