refactor: reuse shared rmbg service for box cutout
This commit is contained in:
parent
055652cc91
commit
0bdc16492d
@ -2,6 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
executeRmbgJob,
|
||||||
|
fetchImageBufferFromUrl,
|
||||||
|
RmbgServiceError,
|
||||||
|
} from "@/lib/ai/rmbg/service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
@ -103,16 +108,6 @@ function applyPadding(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchImageBuffer(imageUrl: string) {
|
|
||||||
const response = await fetch(imageUrl, { cache: "no-store" });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`无法获取图片: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
return Buffer.from(arrayBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -135,7 +130,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||||
const paddingRatio = normalizePaddingRatio(body.paddingRatio);
|
const paddingRatio = normalizePaddingRatio(body.paddingRatio);
|
||||||
|
|
||||||
const sourceBuffer = await sharp(await fetchImageBuffer(resolvedImageUrl), { failOn: "none" })
|
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(resolvedImageUrl), { failOn: "none" })
|
||||||
.rotate()
|
.rotate()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
|
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
|
||||||
@ -166,40 +161,19 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const croppedBase64 = croppedBuffer.toString("base64");
|
const rmbgResult = await executeRmbgJob({
|
||||||
const rmbgUrl = new URL("/api/rmbg", request.url);
|
userId: session.user.id,
|
||||||
const rmbgResponse = await fetch(rmbgUrl, {
|
imageBuffer: croppedBuffer,
|
||||||
method: "POST",
|
imageBase64: croppedBuffer.toString("base64"),
|
||||||
headers: {
|
sourceImageUrl: resolvedImageUrl,
|
||||||
"Content-Type": "application/json",
|
prompt: body.prompt || "框选抠图",
|
||||||
cookie: request.headers.get("cookie") || "",
|
chatId: body.chatId || null,
|
||||||
...(request.headers.get("authorization")
|
messageId: body.messageId || null,
|
||||||
? { authorization: request.headers.get("authorization") as string }
|
skipGallerySave: body.skipGallerySave === true,
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
body: JSON.stringify({
|
|
||||||
image_base64: croppedBase64,
|
|
||||||
sourceImageUrl: resolvedImageUrl,
|
|
||||||
prompt: body.prompt || "框选抠图",
|
|
||||||
chatId: body.chatId || null,
|
|
||||||
messageId: body.messageId || null,
|
|
||||||
skipGallerySave: body.skipGallerySave === true,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rmbgResult = await rmbgResponse.json().catch(() => null);
|
|
||||||
if (!rmbgResponse.ok || !rmbgResult?.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: rmbgResult?.error || rmbgResult?.detail || "框选抠图失败",
|
|
||||||
},
|
|
||||||
{ status: rmbgResponse.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
...rmbgResult,
|
...rmbgResult,
|
||||||
cropRectApplied: cropRect,
|
cropRectApplied: cropRect,
|
||||||
paddedCropRectApplied: paddedCropRect,
|
paddedCropRectApplied: paddedCropRect,
|
||||||
@ -208,6 +182,16 @@ export async function POST(request: NextRequest) {
|
|||||||
sourceNodeId: body.sourceNodeId || null,
|
sourceNodeId: body.sourceNodeId || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof RmbgServiceError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.detail || error.message,
|
||||||
|
},
|
||||||
|
{ status: error.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.error("[BoxCutout] 处理失败:", error);
|
console.error("[BoxCutout] 处理失败:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,509 +1,27 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
createMediaAssetRelation,
|
|
||||||
getMediaAssetsByUserAndUrls,
|
|
||||||
getSystemSetting,
|
|
||||||
upsertMediaAsset,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import {
|
||||||
import { db } from "@/lib/db";
|
executeRmbgJob,
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
fetchImageBufferFromUrl,
|
||||||
|
RmbgServiceError,
|
||||||
|
} from "@/lib/ai/rmbg/service";
|
||||||
|
|
||||||
type RmbgProvider = "rmbg" | "photoroom" | "aoscdn";
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
/**
|
|
||||||
* 压缩图片生成缩略图(使用 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// 0. 获取用户会话
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||||
{ 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") || "";
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
|
||||||
let imageBuffer: Buffer;
|
let imageBuffer: Buffer;
|
||||||
let imageBase64: string;
|
let imageBase64: string | undefined;
|
||||||
let prompt = "";
|
let prompt = "抠图处理";
|
||||||
let chatId: string | null = null;
|
let chatId: string | null = null;
|
||||||
let messageId: string | null = null;
|
let messageId: string | null = null;
|
||||||
let skipGallerySave = false;
|
let skipGallerySave = false;
|
||||||
@ -515,33 +33,15 @@ export async function POST(request: NextRequest) {
|
|||||||
chatId = body.chatId || null;
|
chatId = body.chatId || null;
|
||||||
messageId = body.messageId || null;
|
messageId = body.messageId || null;
|
||||||
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
|
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
|
||||||
|
|
||||||
const imageUrl = body.imageUrl || body.image_url;
|
const imageUrl = body.imageUrl || body.image_url;
|
||||||
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
|
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
|
||||||
|
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
sourceImageUrl = String(imageUrl);
|
sourceImageUrl = String(imageUrl);
|
||||||
console.log(`[RMBG] 从 URL 获取图片: ${imageUrl}`);
|
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||||
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) {
|
} else if (body.image_base64) {
|
||||||
imageBase64 = body.image_base64;
|
imageBase64 = String(body.image_base64);
|
||||||
imageBuffer = Buffer.from(imageBase64, "base64");
|
imageBuffer = Buffer.from(imageBase64, "base64");
|
||||||
if (explicitSourceImageUrl) {
|
if (explicitSourceImageUrl) {
|
||||||
sourceImageUrl = String(explicitSourceImageUrl);
|
sourceImageUrl = String(explicitSourceImageUrl);
|
||||||
@ -554,13 +54,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
} else if (contentType.includes("multipart/form-data")) {
|
} else if (contentType.includes("multipart/form-data")) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||||
{ error: "缺少file参数" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@ -569,6 +66,12 @@ export async function POST(request: NextRequest) {
|
|||||||
prompt = (formData.get("prompt") as string) || "抠图处理";
|
prompt = (formData.get("prompt") as string) || "抠图处理";
|
||||||
chatId = (formData.get("chatId") as string) || null;
|
chatId = (formData.get("chatId") as string) || null;
|
||||||
messageId = (formData.get("messageId") as string) || null;
|
messageId = (formData.get("messageId") as string) || null;
|
||||||
|
skipGallerySave =
|
||||||
|
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
|
||||||
|
sourceImageUrl =
|
||||||
|
(formData.get("sourceImageUrl") as string) ||
|
||||||
|
(formData.get("source_image_url") as string) ||
|
||||||
|
null;
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||||
@ -576,192 +79,32 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 根据提供商调用不同的API
|
const result = await executeRmbgJob({
|
||||||
console.log(`[RMBG] Using provider: ${provider}`);
|
userId: session.user.id,
|
||||||
|
imageBuffer,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
skipGallerySave,
|
||||||
|
sourceImageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
let resultBuffer: Buffer;
|
return NextResponse.json({
|
||||||
let resultBase64: string;
|
success: true,
|
||||||
let processingTime: number;
|
...result,
|
||||||
|
});
|
||||||
if (provider === "photoroom") {
|
} catch (error) {
|
||||||
// 使用 PhotoRoom API
|
if (error instanceof RmbgServiceError) {
|
||||||
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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "图片上传到存储桶失败",
|
error: error.message,
|
||||||
detail: uploadError instanceof Error ? uploadError.message : "未知错误"
|
detail: error.detail || error.message,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
console.error("[RMBG API] 处理错误:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||||
|
|||||||
641
lib/ai/rmbg/service.ts
Normal file
641
lib/ai/rmbg/service.ts
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
import {
|
||||||
|
createMediaAssetRelation,
|
||||||
|
getMediaAssetsByUserAndUrls,
|
||||||
|
getSystemSetting,
|
||||||
|
upsertMediaAsset,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export type RmbgProvider = "rmbg" | "photoroom" | "aoscdn";
|
||||||
|
|
||||||
|
type RmbgConfig = {
|
||||||
|
provider?: RmbgProvider;
|
||||||
|
apiUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
photoroomApiKey?: string;
|
||||||
|
aosApiKey?: string;
|
||||||
|
aosApiBaseUrl?: string;
|
||||||
|
aosForegroundType?: "" | "person" | "object" | "stamp";
|
||||||
|
aosCrop?: 0 | 1;
|
||||||
|
aosFormat?: "png" | "jpg";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecuteRmbgJobParams = {
|
||||||
|
userId: string;
|
||||||
|
imageBuffer: Buffer;
|
||||||
|
imageBase64?: string;
|
||||||
|
prompt?: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
skipGallerySave?: boolean;
|
||||||
|
sourceImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecuteRmbgJobResult = {
|
||||||
|
image_base64: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
original_url: string;
|
||||||
|
processing_time: number;
|
||||||
|
provider: RmbgProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RmbgServiceError extends Error {
|
||||||
|
status: number;
|
||||||
|
detail?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status = 500, detail?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RmbgServiceError";
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const sharp = await import("sharp");
|
||||||
|
|
||||||
|
return await sharp.default(buffer)
|
||||||
|
.resize(500, null, {
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.png({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RMBG] Failed to create thumbnail:", error);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const [thumbnailResult, originalResult] = await Promise.all([
|
||||||
|
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||||
|
uploadFile(originalFilename, originalArrayBuffer),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnail: thumbnailResult.url,
|
||||||
|
original: originalResult.url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RMBG] Failed to upload to S3:", error);
|
||||||
|
throw new RmbgServiceError(
|
||||||
|
"图片上传到存储桶失败",
|
||||||
|
500,
|
||||||
|
error instanceof Error ? error.message : "未知错误"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: resizedBuffer,
|
||||||
|
mimeType,
|
||||||
|
filename: `image.${filenameExt}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AOS RMBG] 图片预处理失败,继续使用原图:", error);
|
||||||
|
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.apiKey) {
|
||||||
|
headers["X-API-Key"] = config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ image_base64: imageBase64 }),
|
||||||
|
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 : "未知错误" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithPhotoRoom(
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const uint8Array = new Uint8Array(imageBuffer);
|
||||||
|
const blob = new Blob([uint8Array], { type: "image/png" });
|
||||||
|
formData.append("imageFile", blob, "image.png");
|
||||||
|
formData.append("referenceBox", "originalImage");
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
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 : "未知错误" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += 1) {
|
||||||
|
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 (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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : "未知错误" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getValidatedRmbgConfig(): Promise<{ provider: RmbgProvider; config: RmbgConfig }> {
|
||||||
|
const rmbgConfig = await getSystemSetting({ key: "rmbg_api_config" });
|
||||||
|
|
||||||
|
if (!rmbgConfig || !rmbgConfig.value) {
|
||||||
|
throw new RmbgServiceError("抠图API配置未设置,请在系统设置中配置", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = rmbgConfig.value as RmbgConfig;
|
||||||
|
const provider: RmbgProvider = config.provider || "rmbg";
|
||||||
|
|
||||||
|
if (provider === "rmbg" && !config.apiUrl) {
|
||||||
|
throw new RmbgServiceError("RMBG API地址未配置", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "photoroom" && !config.photoroomApiKey) {
|
||||||
|
throw new RmbgServiceError("PhotoRoom API密钥未配置", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "aoscdn" && !config.aosApiKey) {
|
||||||
|
throw new RmbgServiceError("AOS API密钥未配置", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { provider, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelId(provider: RmbgProvider) {
|
||||||
|
return provider === "photoroom"
|
||||||
|
? "photoroom-hd"
|
||||||
|
: provider === "aoscdn"
|
||||||
|
? "aos-segmentation"
|
||||||
|
: "rmbg-2.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const imageResponse = await fetch(imageUrl);
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
throw new RmbgServiceError(
|
||||||
|
`无法获取图片: ${imageResponse.status} ${imageResponse.statusText}`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(await imageResponse.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RmbgServiceError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.error("[RMBG] 获取图片失败:", error);
|
||||||
|
throw new RmbgServiceError("无法获取图片,请检查图片链接是否有效", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRmbgJob(
|
||||||
|
params: ExecuteRmbgJobParams
|
||||||
|
): Promise<ExecuteRmbgJobResult> {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
imageBuffer,
|
||||||
|
imageBase64 = imageBuffer.toString("base64"),
|
||||||
|
prompt = "抠图处理",
|
||||||
|
chatId = null,
|
||||||
|
messageId = null,
|
||||||
|
skipGallerySave = false,
|
||||||
|
sourceImageUrl = null,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const { provider, config } = await getValidatedRmbgConfig();
|
||||||
|
console.log(`[RMBG] Using provider: ${provider}`);
|
||||||
|
|
||||||
|
let resultBuffer: Buffer;
|
||||||
|
let resultBase64: string;
|
||||||
|
let processingTime: number;
|
||||||
|
|
||||||
|
if (provider === "photoroom") {
|
||||||
|
const result = await processWithPhotoRoom(imageBuffer, config.photoroomApiKey!);
|
||||||
|
if (!result.success || !result.imageBuffer) {
|
||||||
|
throw new RmbgServiceError("抠图处理失败", 500, result.error);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
throw new RmbgServiceError("抠图处理失败", friendlyMessage ? 400 : 500, friendlyMessage || detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBuffer = result.imageBuffer;
|
||||||
|
resultBase64 = resultBuffer.toString("base64");
|
||||||
|
processingTime = result.processing_time || 0;
|
||||||
|
} else {
|
||||||
|
const result = await processWithRMBG(imageBase64, {
|
||||||
|
apiUrl: config.apiUrl!,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.image_base64) {
|
||||||
|
throw new RmbgServiceError("抠图处理失败", 500, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBase64 = result.image_base64;
|
||||||
|
resultBuffer = Buffer.from(resultBase64, "base64");
|
||||||
|
processingTime = result.processing_time || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = await uploadBufferToS3(resultBuffer, "image/png");
|
||||||
|
const thumbnailUrl = uploadResult.thumbnail;
|
||||||
|
const originalUrl = uploadResult.original;
|
||||||
|
|
||||||
|
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,
|
||||||
|
messageId,
|
||||||
|
thumbnailUrl,
|
||||||
|
originalUrl,
|
||||||
|
imageSize: "2K",
|
||||||
|
prompt,
|
||||||
|
modelId: getModelId(provider),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
saved = true;
|
||||||
|
break;
|
||||||
|
} catch (dbError) {
|
||||||
|
saveError = dbError;
|
||||||
|
if (attempt < 3) {
|
||||||
|
await sleep(120 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
console.error("[RMBG] ❌ 保存到数据库失败:", saveError);
|
||||||
|
throw new RmbgServiceError("图片处理成功但保存到相册失败,请稍后重试", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedImage) {
|
||||||
|
try {
|
||||||
|
const sourceAssets = sourceImageUrl
|
||||||
|
? await getMediaAssetsByUserAndUrls({
|
||||||
|
userId,
|
||||||
|
urls: [sourceImageUrl],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const media = await upsertMediaAsset({
|
||||||
|
userId,
|
||||||
|
chatId,
|
||||||
|
mediaType: "image",
|
||||||
|
sourceType: "edited",
|
||||||
|
status: "ready",
|
||||||
|
originalUrl,
|
||||||
|
previewUrl: originalUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
displayName: prompt.slice(0, 80),
|
||||||
|
prompt,
|
||||||
|
modelId: getModelId(provider),
|
||||||
|
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,
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (assetError) {
|
||||||
|
console.error("[RMBG] ⚠️ MediaAsset 注册失败:", assetError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[RMBG] Skip gallery save by request flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
image_base64: resultBase64,
|
||||||
|
thumbnail_url: thumbnailUrl,
|
||||||
|
original_url: originalUrl,
|
||||||
|
processing_time: processingTime,
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user