fix: harden scene fusion replacement flow
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-05-07 22:28:35 +08:00
parent 965d1f98f3
commit 2814669881
3 changed files with 343 additions and 52 deletions

View File

@ -61,8 +61,9 @@ const SCENE_FUSION_PROCESS_MAX_WORKERS = 1;
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_REVIEW_TIMEOUT_MS = 25_000;
const SCENE_FUSION_STRICT_PROMPT_TAIL =
"硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;若识别不确定,请优先保持第二张图背景不变并完成主体替换。";
"硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;若识别不确定,请优先保持第二张图背景不变并完成主体替换。";
const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL =
"硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。";
const sceneFusionModelCooldown = new Map<string, number>();
@ -91,6 +92,8 @@ interface SceneFusionAgentMeta {
sceneTargets: Array<{
id: string;
targetName?: string;
positionHint?: string;
replaceCue?: string;
source?: "user" | "agent";
confidence?: number;
model?: string;
@ -137,15 +140,6 @@ function rotateCandidates<T>(items: T[], startOffset: number): T[] {
];
}
function shuffleCandidates<T>(items: T[]): T[] {
const shuffled = [...items];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function getNextSceneFusionRoundRobinOffset(poolSize: number): number {
if (poolSize <= 0) return 0;
const offset = sceneFusionRoundRobinCursor % poolSize;
@ -153,6 +147,58 @@ function getNextSceneFusionRoundRobinOffset(poolSize: number): number {
return offset;
}
function getSceneFusionCandidatePriority(candidate: SceneFusionModelCandidate): number {
const providerType = normalizeSceneFusionProviderType(candidate.provider.type);
const modelText = `${candidate.model.displayName} ${candidate.model.modelId}`.toLowerCase();
let score = 0;
switch (providerType) {
case "openai-image":
score += 130;
break;
case "fal-image":
score += 120;
break;
case "alibaba-image":
score += 112;
break;
case "gemini":
score += 104;
break;
case "volcano-vision":
score += 96;
break;
case "ruxa-image":
score += 72;
break;
default:
score += 60;
break;
}
score += candidate.model.supportsVision ? 28 : -18;
if (candidate.model.imageGenConfig?.supports2K !== false) {
score += 4;
}
if (candidate.model.imageGenConfig?.supports4K !== false) {
score += 2;
}
if (/nano-banana|ruxa/i.test(modelText)) {
score -= 18;
}
if (/seedream-5\.0/i.test(modelText)) {
score -= 4;
}
if (/edit|image[-\s]?edit|gpt[-\s]?image/i.test(modelText)) {
score += 6;
}
return score;
}
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
@ -315,6 +361,26 @@ function normalizeSceneFusionName(value: unknown): string {
return normalized.slice(0, 16);
}
function normalizeSceneFusionHint(value: unknown, maxLength: number = 28): string {
const raw = String(value ?? "").trim();
if (!raw) return "";
const normalized = raw
.replace(/[\r\n\t]+/g, " ")
.replace(/[“”"']/g, "")
.replace(/\s+/g, " ")
.trim();
return normalized.slice(0, maxLength);
}
function normalizeSceneFusionBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized) return undefined;
if (["true", "yes", "y", "1", "是"].includes(normalized)) return true;
if (["false", "no", "n", "0", "否"].includes(normalized)) return false;
return undefined;
}
function extractJsonObject(text: string): Record<string, unknown> | null {
const content = String(text || "").trim();
if (!content) return null;
@ -367,37 +433,45 @@ async function detectProductNameWithAgent(imageUrl: string): Promise<{
return { name, confidence, model };
}
async function detectTargetNameWithAgent(
sceneImageUrl: string,
productNameHint: string
): Promise<{
async function detectTargetNameWithAgent(params: {
productImageUrl: string;
sceneImageUrl: string;
productNameHint: string;
}): Promise<{
name: string;
confidence: number;
model: string;
positionHint?: string;
replaceCue?: string;
} | null> {
const prompt = [
"你是场景替换识别助手。",
`已知要替换进场景的产品是:${productNameHint || "该产品"}`,
"请识别当前场景图里最应该被替换的目标物体名称(与该产品品类最接近的场景主体)。",
`图1是待融合的产品参考图,产品名称提示:${params.productNameHint || "该产品"}`,
"图2是待处理的场景图。",
"请识别图2里最应该被图1产品替换的目标物体,并给出简短位置描述,帮助后续模型准确定位。",
"要求:",
"- 返回“完整物体名”,不要返回局部部件名",
"- 名称精简,2-8个字(如:餐椅、沙发、茶几、花瓶)",
"- 如果无法确定,返回最主要主体物体名",
"- positionHint 用 4-16 个字描述目标大致位置(如:画面中央偏左、前景靠窗处、餐桌区域)",
"- replaceCue 用 4-20 个字描述该目标最醒目的识别特征(如:深色办公椅、木质圆餐桌)",
"- 如果无法确定,返回最主要主体物体名,并尽量给出位置",
"仅返回JSON:",
'{\"targetName\":\"名称\",\"confidence\":0.0}',
'{\"targetName\":\"名称\",\"positionHint\":\"位置\",\"replaceCue\":\"识别特征\",\"confidence\":0.0}',
].join("\n");
const { text, model } = await callQwen3Vl({
imageUrl: sceneImageUrl,
imageUrls: [params.productImageUrl, params.sceneImageUrl],
prompt,
temperature: 0.1,
maxTokens: 220,
maxTokens: 320,
timeoutMs: SCENE_FUSION_AGENT_TIMEOUT_MS,
retries: 1,
});
const parsed = extractJsonObject(text);
const name = normalizeSceneFusionName(parsed?.targetName);
const positionHint = normalizeSceneFusionHint(parsed?.positionHint, 20) || undefined;
const replaceCue = normalizeSceneFusionHint(parsed?.replaceCue, 26) || undefined;
const confidenceRaw = Number(parsed?.confidence);
const confidence = Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.7;
@ -405,7 +479,116 @@ async function detectTargetNameWithAgent(
return null;
}
return { name, confidence, model };
return { name, confidence, model, positionHint, replaceCue };
}
async function reviewSceneFusionResult(params: {
requestId: string;
sceneIndex: number;
sceneMode: SceneFusionMode;
productImageUrl: string;
sceneImageUrl: string;
generatedImageUrl: string;
productName?: string;
targetName?: string;
positionHint?: string;
replaceCue?: string;
modelName: string;
}): Promise<{
passed: boolean;
score: number;
reason: string;
model: string;
} | null> {
if (params.sceneMode === "random-bg") {
return null;
}
const targetLabel = params.targetName || "目标主体";
const productLabel = params.productName || "产品";
const locationHints = [params.positionHint, params.replaceCue].filter(Boolean).join(";");
const prompt = [
"你是电商场景替换质检助手。",
"图1是产品参考图,图2是原始场景图,图3是生成结果图。",
`目标任务:把图1中的${productLabel}替换进图2中${targetLabel}所在的位置。`,
locationHints ? `定位提示:${locationHints}` : "",
"请严格判断图3是否同时满足以下条件:",
`1. 图3里已经出现了与图1同主体的${productLabel}`,
`2. 图2原本需要被替换的${targetLabel}没有被完整保留下来`,
"3. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换",
"4. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图",
"若任一条件不满足,就判定为 failed。",
"仅返回 JSON:",
'{"passed":true,"score":0.0,"productMatched":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}',
]
.filter(Boolean)
.join("\n");
try {
const { text, model } = await callQwen3Vl({
imageUrls: [params.productImageUrl, params.sceneImageUrl, params.generatedImageUrl],
prompt,
temperature: 0.1,
maxTokens: 320,
timeoutMs: SCENE_FUSION_REVIEW_TIMEOUT_MS,
retries: 1,
});
const parsed = extractJsonObject(text);
if (!parsed) {
return null;
}
const scoreRaw = Number(parsed.score);
const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(1, scoreRaw)) : 0.5;
const passedFlag = normalizeSceneFusionBoolean(parsed.passed);
const productMatched = normalizeSceneFusionBoolean(parsed.productMatched);
const targetRemoved = normalizeSceneFusionBoolean(parsed.targetRemoved);
const scenePreserved = normalizeSceneFusionBoolean(parsed.scenePreserved);
const suspectedEcho = normalizeSceneFusionBoolean(parsed.suspectedEcho);
const reason =
normalizeSceneFusionHint(parsed.reason, 80) ||
[
productMatched === false ? "未识别出参考产品主体" : "",
targetRemoved === false ? "原目标主体仍明显保留" : "",
scenePreserved === false ? "原场景结构改动过大" : "",
suspectedEcho === true ? "结果疑似回传产品图" : "",
]
.filter(Boolean)
.join(";") ||
"质检结果不明确";
const passed =
passedFlag === true &&
productMatched !== false &&
targetRemoved !== false &&
scenePreserved !== false &&
suspectedEcho !== true &&
score >= 0.55;
console.log(`[SceneFusion][${params.requestId}] Review result:`, {
sceneIndex: params.sceneIndex + 1,
candidate: params.modelName,
passed,
score,
reason,
reviewModel: model,
});
return {
passed,
score,
reason,
model,
};
} catch (error) {
console.warn(`[SceneFusion][${params.requestId}] Review skipped:`, {
sceneIndex: params.sceneIndex + 1,
candidate: params.modelName,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
}
function extractStyleHintFromPrompt(customPrompt: string | undefined): string | undefined {
@ -567,6 +750,8 @@ async function enrichSceneLabelsWithAgent(params: {
sceneTargets: clonedScenes.map((scene) => ({
id: scene.id,
targetName: normalizeSceneFusionName(scene.targetName) || undefined,
positionHint: undefined,
replaceCue: undefined,
source: normalizeSceneFusionName(scene.targetName) ? "user" : undefined,
})),
};
@ -617,15 +802,18 @@ async function enrichSceneLabelsWithAgent(params: {
const sceneIndex = unresolvedIndexes[current]!;
const scene = clonedScenes[sceneIndex]!;
try {
const targetDetected = await detectTargetNameWithAgent(
scene.imageUrl,
resolvedProductName || "产品"
);
const targetDetected = await detectTargetNameWithAgent({
productImageUrl: params.productImageUrl,
sceneImageUrl: scene.imageUrl,
productNameHint: resolvedProductName || "产品",
});
if (!targetDetected?.name) continue;
scene.targetName = targetDetected.name;
const metaScene = meta.sceneTargets.find((item) => item.id === scene.id);
if (metaScene) {
metaScene.targetName = targetDetected.name;
metaScene.positionHint = targetDetected.positionHint;
metaScene.replaceCue = targetDetected.replaceCue;
metaScene.source = "agent";
metaScene.confidence = targetDetected.confidence;
metaScene.model = targetDetected.model;
@ -684,18 +872,23 @@ function buildSceneFusionCandidatePool(
return backendConfiguredImageModels;
}
const prioritySorted = [...backendConfiguredImageModels].sort((left, right) => {
const scoreDiff =
getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left);
if (scoreDiff !== 0) return scoreDiff;
return left.model.displayName.localeCompare(right.model.displayName, "zh-Hans-CN");
});
const preferred = preferredModelId
? backendConfiguredImageModels.find((candidate) => candidate.model.id === preferredModelId)
? prioritySorted.find((candidate) => candidate.model.id === preferredModelId)
: undefined;
if (!preferred) {
return shuffleCandidates(backendConfiguredImageModels);
return prioritySorted;
}
const rest = backendConfiguredImageModels.filter(
(candidate) => candidate.model.id !== preferred.model.id
);
return [preferred, ...shuffleCandidates(rest)];
const rest = prioritySorted.filter((candidate) => candidate.model.id !== preferred.model.id);
return [preferred, ...rest];
}
/**
@ -1161,6 +1354,8 @@ function buildPrompt(
defaultPrompt: string,
productName?: string,
targetName?: string,
positionHint?: string,
replaceCue?: string,
customPrompt?: string,
sceneMode: SceneFusionMode = "scene-fission"
): string {
@ -1169,14 +1364,18 @@ function buildPrompt(
if (sceneMode === "random-bg") {
const subject = productName || "产品";
prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)${subject}外观、材质、颜色与细节保持一致;2)允许轻微调整主体位置与角度以匹配新背景;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`;
} else if (productName && targetName) {
// 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词
// 使用"参考图1"的表述方式,并强调产品的视觉特征
prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中,替换掉场景中的${targetName}。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`;
} else if (productName) {
prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`;
} else if (targetName) {
prompt = `请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中,替换掉场景中的${targetName}。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`;
} else {
const subject = productName || "产品";
const targetSubject = targetName || "场景主主体";
const locationHints = [positionHint, replaceCue].filter(Boolean).join(",");
if (targetName) {
prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)保持${subject}的整体外观、材质、颜色与结构细节和第一张图一致;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)调整${subject}的大小、角度、光影和透视,使其与第二张图自然融合;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`;
} else {
prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)保持${subject}的整体外观、颜色、形状和设计风格与第一张图一致;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)注意光影和透视关系使融合自然。`;
}
if (locationHints) {
prompt = `${prompt} 待替换目标定位:${locationHints}。`;
}
}
// 添加用户补充的关键词
@ -1203,6 +1402,8 @@ async function processSceneWithProvider(
sceneImageUrl: string,
productName: string | undefined,
targetName: string | undefined,
positionHint: string | undefined,
replaceCue: string | undefined,
customPrompt: string | undefined,
defaultPrompt: string,
sceneMode: SceneFusionMode,
@ -1212,7 +1413,15 @@ async function processSceneWithProvider(
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode);
const prompt = buildPrompt(
defaultPrompt,
productName,
targetName,
positionHint,
replaceCue,
customPrompt,
sceneMode
);
console.log(`[SceneFusion] Built prompt: ${prompt}`);
console.log(`[SceneFusion] Provider type: ${provider.type}`);
@ -1530,6 +1739,8 @@ async function processScene(
sceneImageUrl: string,
productName: string | undefined,
targetName: string | undefined,
positionHint: string | undefined,
replaceCue: string | undefined,
customPrompt: string | undefined,
defaultPrompt: string,
sceneMode: SceneFusionMode,
@ -1539,7 +1750,15 @@ async function processScene(
size?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode);
const prompt = buildPrompt(
defaultPrompt,
productName,
targetName,
positionHint,
replaceCue,
customPrompt,
sceneMode
);
console.log(`[SceneFusion] Built prompt: ${prompt}`);
@ -1566,6 +1785,8 @@ async function processSceneWithDiversity(
sceneImageUrl: string;
productName?: string;
targetName?: string;
positionHint?: string;
replaceCue?: string;
customPrompt?: string;
defaultPrompt: string;
sceneMode: SceneFusionMode;
@ -1598,12 +1819,27 @@ async function processSceneWithDiversity(
const healthyCandidates = rotatedCandidates.filter(
(candidate) => !isSceneFusionCandidateCoolingDown(candidate)
);
const orderedHealthyCandidates = [...healthyCandidates].sort((left, right) => {
const scoreDiff =
getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left);
if (scoreDiff !== 0) return scoreDiff;
return 0;
});
const orderedFallbackCandidates = [...rotatedCandidates].sort((left, right) => {
const scoreDiff =
getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left);
if (scoreDiff !== 0) return scoreDiff;
return 0;
});
const attemptCandidates =
healthyCandidates.length > 0 ? healthyCandidates : rotatedCandidates;
orderedHealthyCandidates.length > 0 ? orderedHealthyCandidates : orderedFallbackCandidates;
console.log(
`[SceneFusion][${params.requestId}] Scene#${params.sceneIndex + 1} candidate order: ${attemptCandidates
.map((candidate) => `${candidate.model.displayName}/${candidate.provider.type}`)
.map(
(candidate) =>
`${candidate.model.displayName}/${candidate.provider.type}/score=${getSceneFusionCandidatePriority(candidate)}`
)
.join(" -> ")}`
);
@ -1627,6 +1863,8 @@ async function processSceneWithDiversity(
params.sceneImageUrl,
params.productName,
params.targetName,
params.positionHint,
params.replaceCue,
params.customPrompt,
params.defaultPrompt,
params.sceneMode,
@ -1656,6 +1894,25 @@ async function processSceneWithDiversity(
throw new Error(`生成结果疑似回传产品图(${detail})`);
}
const reviewResult = await reviewSceneFusionResult({
requestId: params.requestId,
sceneIndex: params.sceneIndex,
sceneMode: params.sceneMode,
productImageUrl: params.productImageUrl,
sceneImageUrl: params.sceneImageUrl,
generatedImageUrl: result.original,
productName: params.productName,
targetName: params.targetName,
positionHint: params.positionHint,
replaceCue: params.replaceCue,
modelName: candidate.model.displayName,
});
if (reviewResult && !reviewResult.passed) {
throw new Error(
`生成结果未通过替换质检(score=${reviewResult.score.toFixed(2)},${reviewResult.reason})`
);
}
markSceneFusionCandidateHealthy(candidate);
return {
result,
@ -1918,6 +2175,8 @@ export async function POST(request: Request) {
const scene = processedScenes[currentIndex];
if (!scene) break;
const resolvedScene = resolvedSceneMap.get(scene.id) || scene;
const resolvedMetaScene =
enriched.meta.sceneTargets.find((item) => item.id === resolvedScene.id) || null;
console.log(
`[SceneFusion] ========== Processing scene ${currentIndex + 1}/${processedScenes.length} ==========`
@ -1932,6 +2191,11 @@ export async function POST(request: Request) {
console.log(
`[SceneFusion] Target Name: ${resolvedScene.targetName || "(not specified)"}`
);
if (resolvedMetaScene?.positionHint || resolvedMetaScene?.replaceCue) {
console.log(
`[SceneFusion] Target Hints: position=${resolvedMetaScene?.positionHint || "-"}, cue=${resolvedMetaScene?.replaceCue || "-"}`
);
}
console.log(
`[SceneFusion] Custom Prompt: ${resolvedScene.customPrompt || "(none)"}`
);
@ -1942,6 +2206,8 @@ export async function POST(request: Request) {
sceneImageUrl: resolvedScene.imageUrl,
productName: resolvedProductName,
targetName: resolvedScene.targetName,
positionHint: resolvedMetaScene?.positionHint,
replaceCue: resolvedMetaScene?.replaceCue,
customPrompt: resolvedScene.customPrompt,
defaultPrompt: config.defaultPrompt,
sceneMode: resolvedSceneMode,

View File

@ -25,6 +25,7 @@ interface UploadedImage {
const DEFAULT_IMAGE_SIZE = "2K";
const DEFAULT_TIMEOUT_MS = 300000;
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
const SUPPORTED_ASPECT_RATIOS = [
"1:1",
"1:4",
@ -137,15 +138,28 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
const safeRatio = parseAspectRatioValue(aspectRatio);
const base = baseMap[imageSize] || 1536;
const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS);
let width: number;
let height: number;
if (safeRatio >= 1) {
const width = base;
const height = roundEven(base / safeRatio);
return `${width}x${height}`;
width = roundEven(Math.sqrt(targetPixels * safeRatio));
height = roundEven(width / safeRatio);
} else {
height = roundEven(Math.sqrt(targetPixels / safeRatio));
width = roundEven(height * safeRatio);
}
while (width * height < targetPixels) {
if (safeRatio >= 1) {
width += 2;
height = roundEven(width / safeRatio);
} else {
height += 2;
width = roundEven(height * safeRatio);
}
}
const height = base;
const width = roundEven(base * safeRatio);
return `${width}x${height}`;
}

View File

@ -41,7 +41,8 @@ async function getQwenImageProviderApiKey() {
}
export async function callQwen3Vl(params: {
imageUrl: string;
imageUrl?: string;
imageUrls?: string[];
prompt: string;
temperature?: number;
maxTokens?: number;
@ -52,6 +53,16 @@ export async function callQwen3Vl(params: {
const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`;
const timeoutMs = Math.max(10000, Math.min(params.timeoutMs ?? 45000, 120000));
const retries = Math.max(0, Math.min(params.retries ?? 1, 2));
const imageUrls = [
...(Array.isArray(params.imageUrls) ? params.imageUrls : []),
params.imageUrl || "",
]
.map((item) => String(item || "").trim())
.filter(Boolean);
if (imageUrls.length === 0) {
throw new Error("Qwen3-VL 调用缺少图片输入");
}
let lastError: Error | null = null;
@ -78,12 +89,12 @@ export async function callQwen3Vl(params: {
role: "user",
content: [
{ type: "text", text: params.prompt },
{
...imageUrls.map((url) => ({
type: "image_url",
image_url: {
url: params.imageUrl,
url,
},
},
})),
],
},
],