fix(scene-fusion): guard product-echo outputs and harden pre-rmbg call
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:53:24 +08:00
parent 4ac636f8d1
commit 254511b91a

View File

@ -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<string, number>();
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<Buffer> {
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<string, string>
): Promise<string | null> {
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<string, string>;
}
): 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<void> {
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<string, unknown> | 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<string, unknown> | 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<string, string>;
}
): 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<string, string>();
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";