From 254511b91a3ef984889adcdbe90f9e9f684d64c5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 22:53:24 +0800 Subject: [PATCH] fix(scene-fusion): guard product-echo outputs and harden pre-rmbg call --- app/api/scene-fusion/route.ts | 275 +++++++++++++++++++++++++++------- 1 file changed, 223 insertions(+), 52 deletions(-) diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 87e0da0e5..89a6b6e96 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -41,6 +41,11 @@ const SCENE_FUSION_MAX_ITEMS = 10; const SCENE_FUSION_REQUEST_STAGGER_MS = 350; const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000; const SCENE_FUSION_AGENT_MAX_WORKERS = 2; +const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000; +const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; +const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; +const SCENE_FUSION_STRICT_PROMPT_TAIL = + "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; const sceneFusionModelCooldown = new Map(); let sceneFusionRoundRobinCursor = 0; @@ -146,6 +151,143 @@ function getNextSceneFusionRoundRobinOffset(poolSize: number): number { return offset; } +function buildPreRmbgCandidateUrls(request: Request): string[] { + const urls: string[] = []; + const requestUrl = new URL(request.url); + urls.push(new URL("/api/rmbg", request.url).toString()); + + const host = request.headers.get("host")?.trim(); + const protoHint = request.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + .toLowerCase(); + const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", ""); + + if (host) { + urls.push(`${protocol}://${host}/api/rmbg`); + } + + const localPort = requestUrl.port || process.env.PORT || "3125"; + urls.push(`http://127.0.0.1:${localPort}/api/rmbg`); + urls.push(`http://localhost:${localPort}/api/rmbg`); + + return Array.from(new Set(urls)); +} + +function normalizeUrlForCompare(input: string): string { + try { + const parsed = new URL(input); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return input; + } +} + +async function fetchImageBufferWithTimeout(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`fetch_failed_${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timeoutId); + } +} + +async function getAverageHashBits( + imageUrl: string, + hashCache: Map +): Promise { + if (hashCache.has(imageUrl)) { + return hashCache.get(imageUrl)!; + } + + try { + const buffer = await fetchImageBufferWithTimeout(imageUrl, SCENE_FUSION_ECHO_HASH_TIMEOUT_MS); + const pixels = await sharp(buffer) + .resize(8, 8, { fit: "fill" }) + .grayscale() + .raw() + .toBuffer(); + if (!pixels.length) return null; + const total = pixels.reduce((sum, value) => sum + value, 0); + const avg = total / pixels.length; + const hashBits = Array.from(pixels) + .map((value) => (value >= avg ? "1" : "0")) + .join(""); + hashCache.set(imageUrl, hashBits); + return hashBits; + } catch (error) { + console.warn("[SceneFusion] Echo hash compute skipped:", { + imageUrl: imageUrl.slice(0, 120), + error: error instanceof Error ? error.message : "unknown", + }); + return null; + } +} + +function hammingDistanceBits(a: string, b: string): number { + if (a.length !== b.length) { + return Math.max(a.length, b.length); + } + let distance = 0; + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + distance += 1; + } + } + return distance; +} + +async function detectEchoedProductResult( + params: { + requestId: string; + sceneIndex: number; + productImageUrl: string; + generatedImageUrl: string; + modelName: string; + hashCache: Map; + } +): Promise<{ + tooSimilar: boolean; + reason?: string; + hammingDistance?: number; +}> { + const normalizedProductUrl = normalizeUrlForCompare(params.productImageUrl); + const normalizedGeneratedUrl = normalizeUrlForCompare(params.generatedImageUrl); + + if (normalizedProductUrl === normalizedGeneratedUrl) { + return { tooSimilar: true, reason: "same_url" }; + } + + const [productHash, generatedHash] = await Promise.all([ + getAverageHashBits(params.productImageUrl, params.hashCache), + getAverageHashBits(params.generatedImageUrl, params.hashCache), + ]); + + if (!productHash || !generatedHash) { + return { tooSimilar: false }; + } + + const distance = hammingDistanceBits(productHash, generatedHash); + if (distance <= SCENE_FUSION_ECHO_HASH_THRESHOLD) { + console.warn( + `[SceneFusion][${params.requestId}] Echo-guard hit: scene#${params.sceneIndex + 1}, model=${params.modelName}, distance=${distance}` + ); + return { + tooSimilar: true, + reason: "hash_too_close", + hammingDistance: distance, + }; + } + + return { tooSimilar: false, hammingDistance: distance }; +} + function waitSceneFusion(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -478,64 +620,69 @@ async function preprocessProductImageWithRmbg( }; } - const rmbgUrl = new URL("/api/rmbg", request.url).toString(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90_000); + const candidateUrls = buildPreRmbgCandidateUrls(request); + let lastReason = "no_candidate_url"; - 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, - }); + console.log( + `[SceneFusion][${requestId}] Pre-RMBG start: ${productImageUrl.substring(0, 120)}..., candidates=${candidateUrls.join(" | ")}` + ); - 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", + for (const rmbgUrl of candidateUrls) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS); + try { + const response = await fetch(rmbgUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + cookie, + }, + body: JSON.stringify({ + imageUrl: productImageUrl, + prompt: "场景裂变预处理-抠图", + skipGallerySave: true, + }), + signal: controller.signal, }); - 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", - }; - } + const payload = await response.json().catch(() => null) as Record | null; - 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); + if (!response.ok) { + lastReason = `request_failed_${response.status}`; + console.warn(`[SceneFusion][${requestId}] Pre-RMBG candidate failed: ${rmbgUrl}`, { + status: response.status, + detail: payload?.detail || payload?.error || "unknown", + }); + continue; + } + + const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim(); + if (!cleanedUrl || cleanedUrl === productImageUrl) { + lastReason = cleanedUrl ? "same_as_source" : "empty_result"; + console.warn(`[SceneFusion][${requestId}] Pre-RMBG candidate invalid result: ${rmbgUrl}`, { + reason: lastReason, + }); + continue; + } + + return { + productImageUrl: cleanedUrl, + usedPreRmbg: true, + provider: String(payload?.provider || "unknown"), + }; + } catch (error) { + lastReason = error instanceof Error ? error.message : "exception"; + console.warn(`[SceneFusion][${requestId}] Pre-RMBG candidate exception: ${rmbgUrl}`, error); + } finally { + clearTimeout(timeoutId); + } } + + return { + productImageUrl, + usedPreRmbg: false, + reason: lastReason, + }; } /** @@ -806,6 +953,10 @@ function buildPrompt( if (customPrompt) { prompt = `${prompt} 补充要求:${customPrompt}`; } + + if (!prompt.includes("严禁直接返回第一张参考图")) { + prompt = `${prompt} ${SCENE_FUSION_STRICT_PROMPT_TAIL}`; + } return prompt; } @@ -1144,6 +1295,7 @@ async function processSceneWithDiversity( requestOffset: number; requestId: string; candidates: SceneFusionModelCandidate[]; + imageHashCache: Map; } ): Promise<{ result: { thumbnail: string; original: string; prompt: string }; @@ -1196,6 +1348,23 @@ async function processSceneWithDiversity( params.aspectRatio, params.imageSize ); + + const echoCheck = await detectEchoedProductResult({ + requestId: params.requestId, + sceneIndex: params.sceneIndex, + productImageUrl: params.productImageUrl, + generatedImageUrl: result.original, + modelName: candidate.model.displayName, + hashCache: params.imageHashCache, + }); + if (echoCheck.tooSimilar) { + const detail = + typeof echoCheck.hammingDistance === "number" + ? `hamming=${echoCheck.hammingDistance}` + : echoCheck.reason || "unknown"; + throw new Error(`生成结果疑似回传产品图(${detail})`); + } + markSceneFusionCandidateHealthy(candidate); return { result, @@ -1364,6 +1533,7 @@ export async function POST(request: Request) { const resolvedProductName = enriched.productName; const resolvedScenes = enriched.scenes; const resolvedSceneMap = new Map(resolvedScenes.map((scene) => [scene.id, scene])); + const imageHashCache = new Map(); console.log( `[SceneFusion][${requestId}] Agent labels: product=${resolvedProductName || "(none)"}; targets=${resolvedScenes @@ -1467,6 +1637,7 @@ export async function POST(request: Request) { requestOffset, requestId, candidates: candidateModels, + imageHashCache, }); const dbImageSize = imageSize || "2K";