From 4ac636f8d1bcbadd397cdb8b1f19cbd6044dc21e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 22:07:40 +0800 Subject: [PATCH] feat(scene-fusion): preprocess product with rmbg before fusion --- .../settings/admin-settings-client.tsx | 25 ++++ app/api/rmbg/route.ts | 36 +++--- app/api/scene-fusion/config/route.ts | 3 + app/api/scene-fusion/route.ts | 107 +++++++++++++++++- 4 files changed, 153 insertions(+), 18 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 464e867a1..703991a8a 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -829,6 +829,7 @@ export function AdminSettingsClient() { // 批量处理配置状态 const [sceneFusionConfig, setSceneFusionConfig] = useState({ enabled: true, + preRmbgEnabled: true, defaultPrompt: "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。", maxScenes: 10, modelId: "", // 首选生图模型ID(多样性模式下优先尝试) @@ -1020,12 +1021,14 @@ export function AdminSettingsClient() { if (sceneFusionConfigData?.setting?.value) { const config = sceneFusionConfigData.setting.value as { enabled?: boolean; + preRmbgEnabled?: boolean; defaultPrompt?: string; maxScenes?: number; modelId?: string; }; setSceneFusionConfig({ enabled: config.enabled ?? true, + preRmbgEnabled: config.preRmbgEnabled ?? true, defaultPrompt: config.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。", maxScenes: Math.min(config.maxScenes ?? 10, 10), modelId: config.modelId ?? "", @@ -2117,6 +2120,7 @@ export function AdminSettingsClient() { key: "scene_fusion_config", value: { enabled: sceneFusionConfig.enabled, + preRmbgEnabled: sceneFusionConfig.preRmbgEnabled, defaultPrompt: sceneFusionConfig.defaultPrompt.trim(), maxScenes: Math.max(1, Math.min(10, sceneFusionConfig.maxScenes || 10)), modelId: sceneFusionConfig.modelId, @@ -8661,6 +8665,27 @@ export function AdminSettingsClient() { +
+
+ +
+ 先对产品图做抠图预处理,再进行裂变融合;抠图失败将自动降级为原图继续生成 +
+
+
+ + setSceneFusionConfig({ ...sceneFusionConfig, preRmbgEnabled: checked }) + } + disabled={!sceneFusionConfig.enabled} + /> + + {sceneFusionConfig.preRmbgEnabled ? "已开启" : "已关闭"} + +
+
+ {/* 默认提示词 */}
diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index c0312251c..c7eb256fc 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -501,12 +501,14 @@ export async function POST(request: NextRequest) { let prompt = ""; let chatId: string | null = null; let messageId: string | null = null; + let skipGallerySave = false; 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) { @@ -644,21 +646,25 @@ export async function POST(request: NextRequest) { ); } - // 5. 保存图片记录到数据库 - try { - const [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}`); - } catch (dbError) { - console.error("[RMBG] ❌ 保存到数据库失败:", dbError); + // 5. 保存图片记录到数据库(可选) + if (!skipGallerySave) { + try { + const [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}`); + } catch (dbError) { + console.error("[RMBG] ❌ 保存到数据库失败:", dbError); + } + } else { + console.log("[RMBG] Skip gallery save by request flag"); } // 6. 返回结果 diff --git a/app/api/scene-fusion/config/route.ts b/app/api/scene-fusion/config/route.ts index 9884e8ee2..3d0be993f 100644 --- a/app/api/scene-fusion/config/route.ts +++ b/app/api/scene-fusion/config/route.ts @@ -31,10 +31,12 @@ export async function GET() { enabled?: boolean; defaultPrompt?: string; maxScenes?: number; + preRmbgEnabled?: boolean; }; return NextResponse.json({ enabled: config.enabled ?? true, + preRmbgEnabled: config.preRmbgEnabled ?? true, defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT, maxScenes: Math.min(config.maxScenes ?? SCENE_FUSION_MAX_ITEMS, SCENE_FUSION_MAX_ITEMS), mode: "diversity", @@ -44,6 +46,7 @@ export async function GET() { // 返回默认配置 return NextResponse.json({ enabled: true, + preRmbgEnabled: true, defaultPrompt: FALLBACK_PROMPT, maxScenes: SCENE_FUSION_MAX_ITEMS, mode: "diversity", diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 1d772ae5a..87e0da0e5 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -427,12 +427,14 @@ async function getSceneFusionConfig() { defaultPrompt?: string; maxScenes?: number; modelId?: string; + preRmbgEnabled?: boolean; }; return { enabled: config.enabled ?? true, defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT, maxScenes: Math.min(config.maxScenes ?? SCENE_FUSION_MAX_ITEMS, SCENE_FUSION_MAX_ITEMS), modelId: config.modelId || "", + preRmbgEnabled: config.preRmbgEnabled ?? true, }; } } catch (error) { @@ -444,9 +446,98 @@ async function getSceneFusionConfig() { defaultPrompt: FALLBACK_PROMPT, maxScenes: SCENE_FUSION_MAX_ITEMS, modelId: "", + preRmbgEnabled: true, }; } +async function preprocessProductImageWithRmbg( + request: Request, + requestId: string, + productImageUrl: string, + preRmbgEnabled: boolean +): Promise<{ + productImageUrl: string; + usedPreRmbg: boolean; + provider?: string; + reason?: string; +}> { + if (!preRmbgEnabled) { + return { + productImageUrl, + usedPreRmbg: false, + reason: "disabled_by_config", + }; + } + + const cookie = request.headers.get("cookie"); + if (!cookie) { + return { + productImageUrl, + usedPreRmbg: false, + reason: "missing_cookie", + }; + } + + const rmbgUrl = new URL("/api/rmbg", request.url).toString(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90_000); + + try { + console.log(`[SceneFusion][${requestId}] Pre-RMBG start: ${productImageUrl.substring(0, 120)}...`); + const response = await fetch(rmbgUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + cookie, + }, + body: JSON.stringify({ + imageUrl: productImageUrl, + prompt: "场景裂变预处理-抠图", + skipGallerySave: true, + }), + signal: controller.signal, + }); + + const payload = await response.json().catch(() => null) as Record | null; + + if (!response.ok) { + console.warn(`[SceneFusion][${requestId}] Pre-RMBG fallback: request_failed`, { + status: response.status, + detail: payload?.detail || payload?.error || "unknown", + }); + return { + productImageUrl, + usedPreRmbg: false, + reason: "request_failed", + }; + } + + const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim(); + if (!cleanedUrl || cleanedUrl === productImageUrl) { + return { + productImageUrl, + usedPreRmbg: false, + reason: cleanedUrl ? "same_as_source" : "empty_result", + }; + } + + return { + productImageUrl: cleanedUrl, + usedPreRmbg: true, + provider: String(payload?.provider || "unknown"), + }; + } catch (error) { + console.warn(`[SceneFusion][${requestId}] Pre-RMBG fallback: exception`, error); + return { + productImageUrl, + usedPreRmbg: false, + reason: error instanceof Error ? error.message : "exception", + }; + } finally { + clearTimeout(timeoutId); + } +} + /** * 创建缩略图 */ @@ -1240,6 +1331,16 @@ export async function POST(request: Request) { if (productImage.startsWith("data:")) { productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product"); } + const preprocessedProduct = await preprocessProductImageWithRmbg( + request, + requestId, + productImageUrl, + config.preRmbgEnabled + ); + const fusionProductImageUrl = preprocessedProduct.productImageUrl; + console.log( + `[SceneFusion][${requestId}] Pre-RMBG result: used=${preprocessedProduct.usedPreRmbg}, provider=${preprocessedProduct.provider || "-"}, reason=${preprocessedProduct.reason || "-"}` + ); const processedScenes = await Promise.all( scenes.map(async (scene) => { @@ -1256,7 +1357,7 @@ export async function POST(request: Request) { const enriched = await enrichSceneLabelsWithAgent({ requestId, - productImageUrl, + productImageUrl: fusionProductImageUrl, productName, scenes: processedScenes, }); @@ -1340,7 +1441,7 @@ export async function POST(request: Request) { ); console.log(`[SceneFusion] Scene ID: ${scene.id}`); console.log( - `[SceneFusion] Product Image URL: ${productImageUrl.substring(0, 80)}...` + `[SceneFusion] Product Image URL: ${fusionProductImageUrl.substring(0, 80)}...` ); console.log( `[SceneFusion] Scene Image URL: ${scene.imageUrl.substring(0, 80)}...` @@ -1354,7 +1455,7 @@ export async function POST(request: Request) { try { const { result, candidate, attempts } = await processSceneWithDiversity({ - productImageUrl, + productImageUrl: fusionProductImageUrl, sceneImageUrl: resolvedScene.imageUrl, productName: resolvedProductName, targetName: resolvedScene.targetName,