fix: harden scene fusion replacement flow
This commit is contained in:
parent
965d1f98f3
commit
2814669881
@ -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,
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user