fix(scene-fusion): guard product-echo outputs and harden pre-rmbg call
This commit is contained in:
parent
4ac636f8d1
commit
254511b91a
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user