feat(scene-fusion): preprocess product with rmbg before fusion
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 22:07:40 +08:00
parent d2070d93e1
commit 4ac636f8d1
4 changed files with 153 additions and 18 deletions

View File

@ -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() {
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">场景裂变前自动抠图</Label>
<div className="text-sm text-muted-foreground">
先对产品图做抠图预处理,再进行裂变融合;抠图失败将自动降级为原图继续生成
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={sceneFusionConfig.preRmbgEnabled}
onCheckedChange={(checked) =>
setSceneFusionConfig({ ...sceneFusionConfig, preRmbgEnabled: checked })
}
disabled={!sceneFusionConfig.enabled}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{sceneFusionConfig.preRmbgEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
{/* 默认提示词 */}
<div className="space-y-2">
<Label htmlFor="sceneFusionPrompt">默认提示词 *</Label>

View File

@ -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. 返回结果

View File

@ -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",

View File

@ -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<string, unknown> | 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,