diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index b7aa2b458..bde955f09 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -372,8 +372,11 @@ export function AdminSettingsClient() { // 抠图API配置状态 const [rmbgConfig, setRmbgConfig] = useState({ + provider: "rmbg" as "rmbg" | "photoroom", // 抠图服务提供商 apiUrl: "", apiKey: "", + // PhotoRoom 配置 + photoroomApiKey: "", }); // 变清晰API配置状态 @@ -420,10 +423,17 @@ export function AdminSettingsClient() { const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); useEffect(() => { if (rmbgConfigData?.setting?.value) { - const config = rmbgConfigData.setting.value as { apiUrl?: string; apiKey?: string }; + const config = rmbgConfigData.setting.value as { + provider?: "rmbg" | "photoroom"; + apiUrl?: string; + apiKey?: string; + photoroomApiKey?: string; + }; setRmbgConfig({ + provider: config.provider || "rmbg", apiUrl: config.apiUrl || "", apiKey: config.apiKey || "", + photoroomApiKey: config.photoroomApiKey || "", }); } }, [rmbgConfigData]); @@ -3423,44 +3433,107 @@ export function AdminSettingsClient() { 抠图API配置 - 配置RMBG-2.0背景移除API,用于画图标注功能中的抠图处理 + 配置背景移除API,用于画图标注功能中的抠图处理。支持自部署RMBG和PhotoRoom云服务。 + {/* 服务提供商选择 */}
- - setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })} - placeholder="http://156.225.27.52:8000" - /> -
- 请输入完整的API地址,例如: http://156.225.27.52:8000 -
-
-
- - setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })} - placeholder="如果API需要密钥,请在此输入" - /> -
- 如果API需要认证,请填写API密钥(可选) -
-
-
-
API文档说明
-
-

• API地址应指向RMBG-2.0服务的根地址

-

• 系统会自动调用 /rmbg/base64 接口进行抠图处理

-

• 支持的图片格式:JPG, PNG, JPEG等常见格式

-

• 输出格式:PNG格式的透明背景图片

+ +
+ +
+ + + + {/* RMBG 配置 */} + {rmbgConfig.provider === "rmbg" && ( + <> +
RMBG-2.0 配置
+
+ + setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })} + placeholder="http://156.225.27.52:8000" + /> +
+ 请输入完整的API地址,例如: http://156.225.27.52:8000 +
+
+
+ + setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })} + placeholder="如果API需要密钥,请在此输入" + /> +
+
+
RMBG-2.0 说明
+
+

• 需要自行部署RMBG-2.0服务

+

• 系统会自动调用 /rmbg/base64 接口进行抠图处理

+

• 支持的图片格式:JPG, PNG, JPEG等常见格式

+
+
+ + )} + + {/* PhotoRoom 配置 */} + {rmbgConfig.provider === "photoroom" && ( + <> +
PhotoRoom 配置
+
+ + setRmbgConfig({ ...rmbgConfig, photoroomApiKey: e.target.value })} + placeholder="输入PhotoRoom API密钥" + /> +
+ 在 PhotoRoom 开发者平台获取API密钥 +
+
+
+
PhotoRoom 说明
+
+

• 使用 PhotoRoom 云服务进行高清抠图

+

• 支持 HD 高清背景移除,效果更好

+

• API文档:https://www.photoroom.com/api

+

• 按调用次数计费,请注意用量

+
+
+ + )} + diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index d0c822eab..ff44295b0 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -31,13 +31,10 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise { +async function uploadBufferToS3(buffer: Buffer, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> { try { - // 解析 base64 数据 - const buffer = Buffer.from(base64Data, "base64"); - // 生成文件名 const ext = mimeType.split("/")[1] || "png"; const timestamp = Date.now(); @@ -81,10 +78,146 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/pn } } +/** + * 将 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); +} + +/** + * 使用 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 : "未知错误" }; + } +} + /** * 抠图API路由 - * 从数据库读取RMBG API配置,调用外部API进行抠图处理 - * 将处理后的图片上传到S3并保存到数据库 + * 从数据库读取API配置,支持 RMBG-2.0 和 PhotoRoom 两种服务 */ export async function POST(request: NextRequest) { try { @@ -98,7 +231,8 @@ export async function POST(request: NextRequest) { } const userId = session.user.id; - // 1. 从数据库获取RMBG API配置 + + // 1. 从数据库获取抠图API配置 const rmbgConfig = await getSystemSetting({ key: "rmbg_api_config" }); if (!rmbgConfig || !rmbgConfig.value) { @@ -109,24 +243,37 @@ export async function POST(request: NextRequest) { } const config = rmbgConfig.value as { + provider?: "rmbg" | "photoroom"; apiUrl?: string; apiKey?: string; + photoroomApiKey?: string; }; - if (!config.apiUrl) { + const provider = config.provider || "rmbg"; + + // 验证配置 + if (provider === "rmbg" && !config.apiUrl) { return NextResponse.json( - { error: "抠图API地址未配置" }, + { error: "RMBG API地址未配置" }, + { status: 400 } + ); + } + + if (provider === "photoroom" && !config.photoroomApiKey) { + return NextResponse.json( + { error: "PhotoRoom API密钥未配置" }, { status: 400 } ); } - // 2. 获取请求体(支持 imageUrl、image_base64 或 FormData) + // 2. 获取请求体 const contentType = request.headers.get("content-type") || ""; + let imageBuffer: Buffer; let imageBase64: string; - let prompt = ""; // 用户提示词(可选) - let chatId: string | null = null; // 聊天ID(可选) - let messageId: string | null = null; // 消息ID(可选) + let prompt = ""; + let chatId: string | null = null; + let messageId: string | null = null; if (contentType.includes("application/json")) { const body = await request.json(); @@ -134,9 +281,7 @@ export async function POST(request: NextRequest) { chatId = body.chatId || null; messageId = body.messageId || null; - // 优先使用 imageUrl,其次使用 image_base64 if (body.imageUrl) { - // 从 URL 获取图片并转换为 Base64 console.log(`[RMBG] 从 URL 获取图片: ${body.imageUrl}`); try { const imageResponse = await fetch(body.imageUrl); @@ -147,9 +292,9 @@ export async function POST(request: NextRequest) { ); } const arrayBuffer = await imageResponse.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - imageBase64 = buffer.toString("base64"); - console.log(`[RMBG] 图片已转换为 Base64,大小: ${buffer.length} bytes`); + imageBuffer = Buffer.from(arrayBuffer); + imageBase64 = imageBuffer.toString("base64"); + console.log(`[RMBG] 图片已获取,大小: ${imageBuffer.length} bytes`); } catch (fetchError) { console.error("[RMBG] 获取图片失败:", fetchError); return NextResponse.json( @@ -159,6 +304,7 @@ export async function POST(request: NextRequest) { } } else if (body.image_base64) { imageBase64 = body.image_base64; + imageBuffer = Buffer.from(imageBase64, "base64"); } else { return NextResponse.json( { error: "缺少 imageUrl 或 image_base64 参数" }, @@ -166,7 +312,6 @@ export async function POST(request: NextRequest) { ); } } else if (contentType.includes("multipart/form-data")) { - // FormData格式:文件上传 const formData = await request.formData(); const file = formData.get("file") as File; @@ -177,10 +322,9 @@ export async function POST(request: NextRequest) { ); } - // 将文件转换为Base64 const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - imageBase64 = buffer.toString("base64"); + 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; @@ -191,134 +335,52 @@ export async function POST(request: NextRequest) { ); } - // 3. 调用RMBG API - 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", - }; - - // 如果配置了API Key,添加到请求头(根据RMBG API文档要求使用X-API-Key) - if (config.apiKey) { - headers["X-API-Key"] = config.apiKey; - } - - const startTime = Date.now(); - let response: Response; + // 3. 根据提供商调用不同的API + console.log(`[RMBG] Using provider: ${provider}`); - try { - // 添加超时控制(30秒) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); + let resultBuffer: Buffer; + let resultBase64: string; + let processingTime: number; + + if (provider === "photoroom") { + // 使用 PhotoRoom API + const result = await processWithPhotoRoom(imageBuffer, config.photoroomApiKey!); - response = await fetch(apiUrl, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - } catch (fetchError) { - const processingTime = (Date.now() - startTime) / 1000; - console.error("[RMBG API] 网络请求失败:", { - error: fetchError, - apiUrl, - processingTime, - }); - - // 检查是否是网络连接错误 - if (fetchError instanceof Error) { - // 检查超时错误 - if (fetchError.name === 'AbortError') { - return NextResponse.json( - { - error: "抠图API请求超时", - detail: "请求超过30秒未响应,请检查API服务器状态或网络连接", - }, - { status: 504 } - ); - } - - // 检查错误消息和cause中的网络错误 - const errorMessage = fetchError.message || ''; - const errorCause = (fetchError as any).cause; - const causeMessage = errorCause?.message || ''; - const causeCode = errorCause?.code || ''; - - // 检查各种网络连接错误 - if (errorMessage.includes('EHOSTUNREACH') || - errorMessage.includes('ENOTFOUND') || - errorMessage.includes('ECONNREFUSED') || - errorMessage.includes('No route to host') || - causeMessage.includes('EHOSTUNREACH') || - causeMessage.includes('ENOTFOUND') || - causeMessage.includes('ECONNREFUSED') || - causeMessage.includes('No route to host') || - causeCode === 'EHOSTUNREACH' || - causeCode === 'ENOTFOUND' || - causeCode === 'ECONNREFUSED') { - return NextResponse.json( - { - error: "无法连接到抠图API服务器", - detail: `服务器无法访问 ${apiUrl},请检查:\n1. API服务器是否在线\n2. 网络连接是否正常\n3. 防火墙设置是否正确\n4. 端口8000是否已开放`, - }, - { status: 503 } - ); - } + if (!result.success || !result.imageBuffer) { + return NextResponse.json( + { error: "抠图处理失败", detail: result.error }, + { status: 500 } + ); } - return NextResponse.json( - { - error: "抠图API请求失败", - detail: fetchError instanceof Error ? fetchError.message : "未知网络错误", - }, - { status: 500 } - ); - } - - const processingTime = (Date.now() - startTime) / 1000; - - if (!response.ok) { - const errorText = await response.text(); - console.error("[RMBG API] 请求失败:", { - status: response.status, - statusText: response.statusText, - error: errorText, - apiUrl, + 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, }); - return NextResponse.json( - { - error: "抠图API请求失败", - detail: errorText || response.statusText, - }, - { status: response.status } - ); + 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; } - const result = await response.json(); - - // 4. 处理返回结果 - if (!result.success || !result.image_base64) { - return NextResponse.json( - { error: "抠图处理失败", detail: result.detail || "未知错误" }, - { status: 500 } - ); - } - - // 5. 上传处理后的图片到S3存储桶 + // 4. 上传处理后的图片到S3 let thumbnailUrl: string; let originalUrl: string; try { - const uploadResult = await uploadBase64ToS3(result.image_base64, "image/png"); + const uploadResult = await uploadBufferToS3(resultBuffer, "image/png"); thumbnailUrl = uploadResult.thumbnail; originalUrl = uploadResult.original; console.log(`[RMBG] ✅ Images uploaded to S3 successfully`); @@ -333,7 +395,7 @@ export async function POST(request: NextRequest) { ); } - // 6. 保存图片记录到数据库(相册和生图记录) + // 5. 保存图片记录到数据库 try { const [savedImage] = await db.insert(generatedImage).values({ userId, @@ -341,31 +403,23 @@ export async function POST(request: NextRequest) { messageId: messageId || null, thumbnailUrl, originalUrl, - imageSize: "2K", // 默认尺寸 + imageSize: "2K", prompt: prompt || "抠图处理", - modelId: "rmbg-2.0", // 标记为抠图处理 + modelId: provider === "photoroom" ? "photoroom-hd" : "rmbg-2.0", }).returning(); - console.log(`[RMBG] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`); + console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`); } catch (dbError) { console.error("[RMBG] ❌ 保存到数据库失败:", dbError); - // 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问) - // 如果希望严格保证数据一致性,可以取消注释下面的代码来返回错误 - // return NextResponse.json( - // { - // error: "图片已处理但保存到相册失败", - // detail: dbError instanceof Error ? dbError.message : "未知错误" - // }, - // { status: 500 } - // ); } - // 7. 返回结果(包含S3 URL和Base64,前端可以选择使用) + // 6. 返回结果 return NextResponse.json({ success: true, - image_base64: result.image_base64, // 保留Base64用于前端立即显示 - thumbnail_url: thumbnailUrl, // S3缩略图URL - original_url: originalUrl, // S3原图URL - processing_time: result.processing_time || processingTime, + image_base64: resultBase64, + thumbnail_url: thumbnailUrl, + original_url: originalUrl, + processing_time: processingTime, + provider: provider, }); } catch (error) { console.error("[RMBG API] 处理错误:", error); @@ -375,4 +429,3 @@ export async function POST(request: NextRequest) { ); } } -