Your Name 0a2b8b3045
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
serialize batch image processing requests
2026-03-11 09:25:17 +08:00

2050 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 场景裂变 API
*
* 将一张产品图批量融合到多个场景图中
* 支持多种生图模型:阿里生图、RUXA、Gemini、既梦
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import {
createMediaAssetRelation,
getEnabledAiModelsWithProvider,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import sharp from "sharp";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import {
canProcessSceneFusionProviderType,
normalizeSceneFusionOutputParamsForCandidate,
normalizeSceneFusionProviderType,
type SceneFusionImageGenConfig,
} from "@/lib/scene-fusion/output-capabilities";
// 请求体 Schema
const sceneFusionRequestSchema = z.object({
productImage: z.string().min(1, "产品图不能为空"),
productName: z.string().optional(), // 产品名称(如"沙发")
sceneMode: z.enum(["scene-fission", "multi-product-scene", "random-bg"]).optional(),
aspectRatio: z.string().optional(), // 图片比例(如"1:1", "4:3", "auto")
imageSize: z.string().optional(), // 图片分辨率(如"1K", "2K", "4K")
scenes: z.array(z.object({
id: z.string(),
image: z.string().min(1, "场景图不能为空"),
targetName: z.string().optional(), // 背景中需要被替换的物体名称(如"椅子")
customPrompt: z.string().optional(),
})).min(1, "至少需要一个场景图").max(10, "最多支持10个场景图"),
});
type SceneFusionMode = "scene-fission" | "multi-product-scene" | "random-bg";
// 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式
const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。";
const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000;
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 = 1;
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_STRICT_PROMPT_TAIL =
"硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;若识别不确定,请优先保持第二张图背景不变并完成主体替换。";
const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL =
"硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。";
const sceneFusionModelCooldown = new Map<string, number>();
let sceneFusionRoundRobinCursor = 0;
interface SceneFusionModelCandidate {
model: {
id: string;
modelId: string;
displayName: string;
supportsVision: boolean;
imageGenConfig?: SceneFusionImageGenConfig | null;
};
provider: {
type: string;
baseUrl: string;
apiKey: string;
};
}
interface SceneFusionAgentMeta {
productName?: string;
productSource?: "user" | "agent";
productConfidence?: number;
productModel?: string;
sceneTargets: Array<{
id: string;
targetName?: string;
source?: "user" | "agent";
confidence?: number;
model?: string;
}>;
}
function pruneSceneFusionCooldown(now: number = Date.now()) {
for (const [key, expireAt] of sceneFusionModelCooldown.entries()) {
if (expireAt <= now) {
sceneFusionModelCooldown.delete(key);
}
}
}
function isSceneFusionCandidateCoolingDown(
candidate: SceneFusionModelCandidate,
now: number = Date.now()
): boolean {
const expireAt = sceneFusionModelCooldown.get(candidate.model.id);
if (!expireAt) return false;
if (expireAt <= now) {
sceneFusionModelCooldown.delete(candidate.model.id);
return false;
}
return true;
}
function markSceneFusionCandidateFailed(candidate: SceneFusionModelCandidate, now: number = Date.now()) {
sceneFusionModelCooldown.set(candidate.model.id, now + SCENE_FUSION_MODEL_COOLDOWN_MS);
}
function markSceneFusionCandidateHealthy(candidate: SceneFusionModelCandidate) {
sceneFusionModelCooldown.delete(candidate.model.id);
}
function rotateCandidates<T>(items: T[], startOffset: number): T[] {
if (items.length <= 1) {
return [...items];
}
const normalizedOffset = ((startOffset % items.length) + items.length) % items.length;
return [
...items.slice(normalizedOffset),
...items.slice(0, normalizedOffset),
];
}
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;
sceneFusionRoundRobinCursor = (sceneFusionRoundRobinCursor + 1) % Number.MAX_SAFE_INTEGER;
return offset;
}
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
const requestHost = requestUrl.hostname.toLowerCase();
const requestProtocol = requestUrl.protocol.replace(":", "");
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
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) {
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
if (!(protocol === "https" && isLocalHost)) {
urls.push(`${protocol}://${host}/api/rmbg`);
}
if (isLocalHost) {
urls.push(`http://${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));
}
function normalizeSceneFusionName(value: unknown): string {
const raw = String(value ?? "").trim();
if (!raw) return "";
const normalized = raw
.replace(/[::,,。.!!??、;;()()[\]{}"'`]/g, " ")
.replace(/\s+/g, " ")
.trim();
return normalized.slice(0, 16);
}
function extractJsonObject(text: string): Record<string, unknown> | null {
const content = String(text || "").trim();
if (!content) return null;
const match = content.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
const parsed = JSON.parse(match[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
async function detectProductNameWithAgent(imageUrl: string): Promise<{
name: string;
confidence: number;
model: string;
} | null> {
const prompt = [
"你是电商图片分析助手。",
"请识别图片中最核心、最主要的产品主体名称。",
"要求:",
"- 名称精简,2-8个字,尽量包含材质/品类(如:皮质沙发、木质餐椅、陶瓷花瓶)",
"- 不要返回颜色描述句子,不要返回背景和场景词",
"仅返回JSON:",
'{\"productName\":\"名称\",\"confidence\":0.0}',
].join("\n");
const { text, model } = await callQwen3Vl({
imageUrl,
prompt,
temperature: 0.1,
maxTokens: 220,
timeoutMs: SCENE_FUSION_AGENT_TIMEOUT_MS,
retries: 1,
});
const parsed = extractJsonObject(text);
const name = normalizeSceneFusionName(parsed?.productName);
const confidenceRaw = Number(parsed?.confidence);
const confidence = Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.7;
if (!name || confidence < 0.45) {
return null;
}
return { name, confidence, model };
}
async function detectTargetNameWithAgent(
sceneImageUrl: string,
productNameHint: string
): Promise<{
name: string;
confidence: number;
model: string;
} | null> {
const prompt = [
"你是场景替换识别助手。",
`已知要替换进场景的产品是:${productNameHint || "该产品"}`,
"请识别当前场景图里最应该被替换的目标物体名称(与该产品品类最接近的场景主体)。",
"要求:",
"- 返回“完整物体名”,不要返回局部部件名",
"- 名称精简,2-8个字(如:餐椅、沙发、茶几、花瓶)",
"- 如果无法确定,返回最主要主体物体名",
"仅返回JSON:",
'{\"targetName\":\"名称\",\"confidence\":0.0}',
].join("\n");
const { text, model } = await callQwen3Vl({
imageUrl: sceneImageUrl,
prompt,
temperature: 0.1,
maxTokens: 220,
timeoutMs: SCENE_FUSION_AGENT_TIMEOUT_MS,
retries: 1,
});
const parsed = extractJsonObject(text);
const name = normalizeSceneFusionName(parsed?.targetName);
const confidenceRaw = Number(parsed?.confidence);
const confidence = Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.7;
if (!name || confidence < 0.4) {
return null;
}
return { name, confidence, model };
}
function extractStyleHintFromPrompt(customPrompt: string | undefined): string | undefined {
const content = String(customPrompt || "").trim();
if (!content) return undefined;
const quoted = content.match(/[“"]([^”"]+?)(?:风格|场景)/);
if (quoted?.[1]) {
return normalizeSceneFusionName(quoted[1]) || quoted[1].trim();
}
const styleOnly = content.match(/(?:替换背景为|背景为|风格为)\s*([^,。;]+)/);
if (styleOnly?.[1]) {
return normalizeSceneFusionName(styleOnly[1]) || styleOnly[1].trim();
}
return undefined;
}
function mergeCustomPrompt(basePrompt: string | undefined, addition: string | undefined): string | undefined {
const base = String(basePrompt || "").trim();
const extra = String(addition || "").trim();
if (!base && !extra) return undefined;
if (!base) return extra.slice(0, 280);
if (!extra) return base.slice(0, 280);
if (base.includes(extra)) return base.slice(0, 280);
if (extra.includes(base)) return extra.slice(0, 280);
return `${base} ${extra}`.slice(0, 280);
}
async function generateRandomBackgroundPromptWithAgent(params: {
productImageUrl: string;
productNameHint?: string;
styleHint?: string;
userPrompt?: string;
}): Promise<string | null> {
const prompt = [
"你是电商场景策划助手。",
`产品名称:${params.productNameHint || "该产品"}`,
params.styleHint ? `期望风格:${params.styleHint}` : "",
params.userPrompt ? `用户补充要求:${params.userPrompt}` : "",
"请基于产品图生成一条“仅描述背景场景”的中文提示词。",
"要求:",
"- 场景必须与产品品类匹配,适合商品展示",
"- 只描述背景、灯光、材质氛围,不改动产品主体外观",
"- 不要输出流程指令(例如“替换背景/保持主体”)",
"- 20~60字,语义具体可执行",
"仅返回 JSON:",
"{\"backgroundPrompt\":\"...\"}",
]
.filter(Boolean)
.join("\n");
const { text } = await callQwen3Vl({
imageUrl: params.productImageUrl,
prompt,
temperature: 0.2,
maxTokens: 240,
timeoutMs: SCENE_FUSION_AGENT_TIMEOUT_MS,
retries: 1,
});
const parsed = extractJsonObject(text);
const backgroundPromptRaw = String(parsed?.backgroundPrompt || "").replace(/\s+/g, " ").trim();
if (!backgroundPromptRaw || backgroundPromptRaw.length < 6) {
return null;
}
return backgroundPromptRaw.slice(0, 120);
}
async function enrichRandomBackgroundScenesWithAgent(params: {
requestId: string;
productImageUrl: string;
productName?: string;
scenes: Array<{
id: string;
image: string;
targetName?: string;
customPrompt?: string;
imageUrl: string;
}>;
}): Promise<Array<{
id: string;
image: string;
targetName?: string;
customPrompt?: string;
imageUrl: string;
}>> {
const nextScenes = params.scenes.map((scene) => ({ ...scene }));
const workerTargets = nextScenes;
if (workerTargets.length === 0) return nextScenes;
let cursor = 0;
const workerCount = Math.max(1, Math.min(SCENE_FUSION_AGENT_MAX_WORKERS, workerTargets.length));
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const current = cursor;
cursor += 1;
if (current >= workerTargets.length) break;
const scene = workerTargets[current];
if (!scene) continue;
try {
if (!normalizeSceneFusionName(scene.targetName) && params.productName) {
scene.targetName = params.productName;
}
const styleHint = extractStyleHintFromPrompt(scene.customPrompt);
const generatedPrompt = await generateRandomBackgroundPromptWithAgent({
productImageUrl: params.productImageUrl,
productNameHint: params.productName,
styleHint,
userPrompt: scene.customPrompt,
});
if (generatedPrompt) {
scene.customPrompt = mergeCustomPrompt(scene.customPrompt, `场景建议:${generatedPrompt}`);
}
} catch (error) {
console.warn(
`[SceneFusion][${params.requestId}] Random scene prompt enhance skipped: scene=${scene.id}`,
error
);
}
}
});
await Promise.all(workers);
return nextScenes;
}
async function enrichSceneLabelsWithAgent(params: {
requestId: string;
productImageUrl: string;
productName?: string;
scenes: Array<{
id: string;
image: string;
targetName?: string;
customPrompt?: string;
imageUrl: string;
}>;
}): Promise<{
productName?: string;
scenes: Array<{
id: string;
image: string;
targetName?: string;
customPrompt?: string;
imageUrl: string;
}>;
meta: SceneFusionAgentMeta;
}> {
const clonedScenes = params.scenes.map((scene) => ({ ...scene }));
const userProductName = normalizeSceneFusionName(params.productName);
let resolvedProductName = userProductName;
const meta: SceneFusionAgentMeta = {
productName: userProductName || undefined,
productSource: userProductName ? "user" : undefined,
sceneTargets: clonedScenes.map((scene) => ({
id: scene.id,
targetName: normalizeSceneFusionName(scene.targetName) || undefined,
source: normalizeSceneFusionName(scene.targetName) ? "user" : undefined,
})),
};
try {
const productDetected = await detectProductNameWithAgent(params.productImageUrl);
if (productDetected?.name) {
if (!resolvedProductName) {
resolvedProductName = productDetected.name;
meta.productSource = "agent";
} else {
meta.productSource = "user";
}
meta.productName = resolvedProductName || productDetected.name;
meta.productConfidence = productDetected.confidence;
meta.productModel = productDetected.model;
}
} catch (error) {
console.warn(`[SceneFusion][${params.requestId}] Product detect skipped:`, error);
}
const unresolvedIndexes = clonedScenes
.map((scene, index) => ({
index,
hasTarget: Boolean(normalizeSceneFusionName(scene.targetName)),
sameAsProduct: scene.imageUrl === params.productImageUrl,
}))
.filter((item) => !item.hasTarget && !item.sameAsProduct)
.map((item) => item.index);
if (unresolvedIndexes.length === 0) {
return {
productName: resolvedProductName || undefined,
scenes: clonedScenes,
meta,
};
}
let cursor = 0;
const workerCount = Math.max(1, Math.min(SCENE_FUSION_AGENT_MAX_WORKERS, unresolvedIndexes.length));
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const current = cursor;
cursor += 1;
if (current >= unresolvedIndexes.length) break;
const sceneIndex = unresolvedIndexes[current]!;
const scene = clonedScenes[sceneIndex]!;
try {
const targetDetected = await detectTargetNameWithAgent(
scene.imageUrl,
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.source = "agent";
metaScene.confidence = targetDetected.confidence;
metaScene.model = targetDetected.model;
}
} catch (error) {
console.warn(
`[SceneFusion][${params.requestId}] Scene target detect skipped: scene=${scene.id}`,
error
);
}
}
});
await Promise.all(workers);
return {
productName: resolvedProductName || undefined,
scenes: clonedScenes,
meta,
};
}
function buildSceneFusionCandidatePool(
allModels: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>,
preferredModelId: string
): SceneFusionModelCandidate[] {
const backendConfiguredImageModels = allModels
.filter((entry) => {
if (!entry?.model?.isEnabled) return false;
if (!entry?.model?.supportsImageGen) return false;
if (entry?.model?.isSubModel) return false;
return true;
})
.map((entry) => ({
model: {
id: String(entry.model.id),
modelId: String(entry.model.modelId || ""),
displayName: String(entry.model.displayName || entry.model.modelId || entry.model.id),
supportsVision: Boolean(entry.model.supportsVision),
imageGenConfig: (entry.model.imageGenConfig || null) as SceneFusionImageGenConfig | null,
},
provider: {
type: normalizeSceneFusionProviderType(String(entry.provider.type || "")),
baseUrl: String(entry.provider.baseUrl || ""),
apiKey: String(entry.provider.apiKey || ""),
},
}))
.filter(
(candidate) =>
Boolean(candidate.model.modelId) &&
Boolean(candidate.provider.type) &&
Boolean(candidate.provider.apiKey)
);
if (backendConfiguredImageModels.length <= 1) {
return backendConfiguredImageModels;
}
const preferred = preferredModelId
? backendConfiguredImageModels.find((candidate) => candidate.model.id === preferredModelId)
: undefined;
if (!preferred) {
return shuffleCandidates(backendConfiguredImageModels);
}
const rest = backendConfiguredImageModels.filter(
(candidate) => candidate.model.id !== preferred.model.id
);
return [preferred, ...shuffleCandidates(rest)];
}
/**
* 获取场景裂变配置
*/
async function getSceneFusionConfig() {
try {
const setting = await getSystemSetting({ key: "scene_fusion_config" });
if (setting?.value) {
const config = setting.value as {
enabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
modelId?: string;
preRmbgEnabled?: boolean;
};
return {
enabled: config.enabled ?? true,
defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT,
maxScenes: Math.min(config.maxScenes ?? SCENE_FUSION_MAX_ITEMS, SCENE_FUSION_MAX_ITEMS),
modelId: config.modelId || "",
preRmbgEnabled: config.preRmbgEnabled ?? true,
};
}
} catch (error) {
console.error("[SceneFusion] Failed to load config:", error);
}
return {
enabled: true,
defaultPrompt: FALLBACK_PROMPT,
maxScenes: SCENE_FUSION_MAX_ITEMS,
modelId: "",
preRmbgEnabled: true,
};
}
async function preprocessProductImageWithRmbg(
request: Request,
requestId: string,
productImageUrl: string,
preRmbgEnabled: boolean
): Promise<{
productImageUrl: string;
usedPreRmbg: boolean;
provider?: string;
reason?: string;
}> {
if (!preRmbgEnabled) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "disabled_by_config",
};
}
const cookie = request.headers.get("cookie");
if (!cookie) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "missing_cookie",
};
}
const candidateUrls = buildPreRmbgCandidateUrls(request);
let lastReason = "no_candidate_url";
console.log(
`[SceneFusion][${requestId}] Pre-RMBG start: ${productImageUrl.substring(0, 120)}..., candidates=${candidateUrls.join(" | ")}`
);
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,
});
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
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,
};
}
/**
* 创建缩略图
*/
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const thumbnailBuffer = await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
return thumbnailBuffer;
} catch (error) {
console.error("[SceneFusion] Failed to create thumbnail:", error);
return buffer;
}
}
/**
* 生成唯一文件名
*/
function generateFilename(prefix: string, extension: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
/**
* 将 Base64 图片上传到 S3 获取 URL
*/
async function uploadBase64ToS3(base64Data: string, prefix: string = "scene-fusion"): Promise<string> {
try {
// 移除 data URL 前缀
const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Clean, "base64");
const filename = generateFilename(`${prefix}-ref`, "png");
const result = await uploadFile(filename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
console.log(`[SceneFusion] Uploaded reference image to: ${result.url}`);
return result.url;
} catch (error) {
console.error("[SceneFusion] Failed to upload reference image:", error);
throw error;
}
}
/**
* 下载图片并上传到 S3
*/
async function downloadAndUploadToS3(
imageUrl: string,
prefix: string = "scene-fusion"
): Promise<{ thumbnail: string; original: string }> {
try {
console.log("[SceneFusion] Downloading image from:", imageUrl.substring(0, 100) + "...");
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
console.log(`[SceneFusion] Downloaded image, size: ${buffer.length} bytes`);
// 创建缩略图
const thumbnailBuffer = await createThumbnail(buffer);
// 上传原图
const originalFilename = generateFilename(`${prefix}-orig`, "png");
const originalResult = await uploadFile(originalFilename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
console.log(`[SceneFusion] Uploaded original to: ${originalResult.url}`);
// 上传缩略图
const thumbnailFilename = generateFilename(`${prefix}-thumb`, "jpg");
const thumbnailArrayBuffer = new Uint8Array(thumbnailBuffer).buffer;
const thumbnailResult = await uploadFile(thumbnailFilename, thumbnailArrayBuffer);
console.log(`[SceneFusion] Uploaded thumbnail to: ${thumbnailResult.url}`);
return {
original: originalResult.url,
thumbnail: thumbnailResult.url,
};
} catch (error) {
console.error("[SceneFusion] Failed to download/upload image:", error);
throw error;
}
}
async function saveSceneFusionImageRecord(params: {
userId: string;
thumbnailUrl: string;
originalUrl: string;
imageSize: string;
prompt: string;
modelId: string;
sourceImageUrls?: string[];
}): Promise<void> {
let lastError: unknown = null;
let savedImage:
| {
id: string;
}
| null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
([savedImage] = await db.insert(generatedImage).values({
userId: params.userId,
chatId: null,
messageId: null,
thumbnailUrl: params.thumbnailUrl,
originalUrl: params.originalUrl,
imageSize: params.imageSize,
prompt: params.prompt,
modelId: params.modelId,
}).returning());
break;
} catch (error) {
lastError = error;
if (attempt < 3) {
await waitSceneFusion(120 * attempt);
}
}
}
if (!savedImage) {
throw lastError instanceof Error ? lastError : new Error("保存到相册失败");
}
try {
const sourceAssets =
Array.isArray(params.sourceImageUrls) && params.sourceImageUrls.length > 0
? await getMediaAssetsByUserAndUrls({
userId: params.userId,
urls: params.sourceImageUrls,
})
: [];
const media = await upsertMediaAsset({
userId: params.userId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: params.originalUrl,
previewUrl: params.originalUrl,
thumbnailUrl: params.thumbnailUrl,
displayName: params.prompt.slice(0, 80),
prompt: params.prompt,
modelId: params.modelId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
imageSize: params.imageSize,
sourceImageUrls: params.sourceImageUrls ?? [],
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: params.userId,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
metadata: {
prompt: params.prompt,
},
});
}
}
} catch (assetError) {
console.error("[SceneFusion] ⚠️ MediaAsset 注册失败:", assetError);
}
}
/**
* 根据 aspectRatio 和 imageSize 计算实际尺寸
* @param aspectRatio - 宽高比,如 "1:1", "4:3", "16:9", "auto"
* @param imageSize - 分辨率等级,如 "1K", "2K", "4K"
* @returns 尺寸字符串,如 "1024*1024",或 undefined(auto 模式)
*/
function calculateSize(aspectRatio: string, imageSize: string): string | undefined {
// auto 模式:不设置尺寸,让 API 根据输入图片自动决定
if (aspectRatio === "auto") {
return undefined;
}
// 根据分辨率等级确定基准像素
const baseSizeMap: Record<string, number> = {
"1K": 1024,
"2K": 1536,
"4K": 2048,
};
const baseSize = baseSizeMap[imageSize] || 1536;
// 根据比例计算宽高
const ratioMap: Record<string, [number, number]> = {
"1:1": [1, 1],
"4:3": [4, 3],
"3:4": [3, 4],
"16:9": [16, 9],
"9:16": [9, 16],
};
const ratio = ratioMap[aspectRatio];
if (!ratio) {
return undefined;
}
const [w, h] = ratio;
// 保持总像素接近 baseSize * baseSize,同时维持比例
const scale = Math.sqrt((baseSize * baseSize) / (w * h));
let width = Math.round(w * scale);
let height = Math.round(h * scale);
// 确保宽高在 512-2048 范围内
const clamp = (v: number) => Math.max(512, Math.min(2048, v));
width = clamp(width);
height = clamp(height);
return `${width}*${height}`;
}
/**
* 调用阿里生图 API
*/
async function callAlibabaImageAPI(
baseURL: string,
apiKey: string,
modelId: string,
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
size?: string
): Promise<string> {
// 构建消息内容:产品图 + 场景图 + 提示词
const content = [
{ image: productImageUrl },
{ image: sceneImageUrl },
{ text: prompt },
];
const requestBody = {
model: modelId,
input: {
messages: [
{
role: "user",
content,
},
],
},
parameters: {
n: 1,
...(size && { size }),
prompt_extend: true,
watermark: false,
},
};
console.log(`[SceneFusion][${modelId}] Request Debug:
- Index: Scene ${sceneImageUrl.substring(sceneImageUrl.length-15)}
- Product URL: ${productImageUrl}
- Scene URL: ${sceneImageUrl}
- Size: ${size || 'auto'}
- Prompt: ${prompt}
`);
const response = await fetch(baseURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
// 打印完整响应用于调试
console.log(`[SceneFusion][${modelId}] Full API Response:`, JSON.stringify(data, null, 2));
if (!response.ok || data.code) {
console.error("[SceneFusion] API Error Response:", JSON.stringify(data, null, 2));
throw new Error(data.message || (data.code ? `Error ${data.code}: ${data.message}` : `API request failed: ${response.status}`));
}
// 提取生成的图片 URL
const choices = data.output?.choices;
if (!choices || choices.length === 0) {
console.error("[SceneFusion] No choices in response, output:", data.output);
throw new Error("No images in response");
}
const imageContent = choices[0]?.message?.content;
console.log(`[SceneFusion] Image content array:`, JSON.stringify(imageContent, null, 2));
if (!imageContent || imageContent.length === 0) {
throw new Error("No image content in response");
}
// 遍历所有内容项,找到 image 类型的
let generatedImageUrl: string | undefined;
for (const item of imageContent) {
if (item.image) {
generatedImageUrl = item.image;
console.log(`[SceneFusion] Found image URL in content item`);
break;
}
}
if (!generatedImageUrl) {
console.error("[SceneFusion] No image URL found in content:", imageContent);
throw new Error("No image URL in response");
}
// 检查返回的图片 URL 是否与输入相同(可能是 API 返回了原图)
if (generatedImageUrl === productImageUrl || generatedImageUrl === sceneImageUrl) {
console.warn(`[SceneFusion] ⚠️ WARNING: API returned same URL as input!`);
console.warn(`[SceneFusion] - Generated URL: ${generatedImageUrl}`);
console.warn(`[SceneFusion] - Product URL: ${productImageUrl}`);
console.warn(`[SceneFusion] - Scene URL: ${sceneImageUrl}`);
}
console.log("[SceneFusion] Generated image URL:", generatedImageUrl.substring(0, 100) + "...");
return generatedImageUrl;
}
/**
* 构建智能提示词
* @param defaultPrompt - 默认提示词模板
* @param productName - 产品名称(如"沙发")
* @param targetName - 背景中需要被替换的物体名称(如"椅子")
* @param customPrompt - 用户补充的关键词
*/
function buildPrompt(
defaultPrompt: string,
productName?: string,
targetName?: string,
customPrompt?: string,
sceneMode: SceneFusionMode = "scene-fission"
): string {
let prompt = defaultPrompt;
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)注意光影和透视关系使融合自然。`;
}
// 添加用户补充的关键词
if (customPrompt) {
prompt = `${prompt} 补充要求:${customPrompt}`;
}
if (sceneMode === "random-bg") {
if (!prompt.includes("背景必须与原图明显不同")) {
prompt = `${prompt} ${SCENE_FUSION_RANDOM_BG_PROMPT_TAIL}`;
}
} else if (!prompt.includes("严禁直接返回第一张参考图")) {
prompt = `${prompt} ${SCENE_FUSION_STRICT_PROMPT_TAIL}`;
}
return prompt;
}
/**
* 通用场景处理函数 - 根据 provider 类型调用不同的实现
*/
async function processSceneWithProvider(
productImageUrl: string,
sceneImageUrl: string,
productName: string | undefined,
targetName: string | undefined,
customPrompt: string | undefined,
defaultPrompt: string,
sceneMode: SceneFusionMode,
provider: { type: string; baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode);
console.log(`[SceneFusion] Built prompt: ${prompt}`);
console.log(`[SceneFusion] Provider type: ${provider.type}`);
// 根据 provider 类型选择不同的处理逻辑
switch (provider.type) {
case "alibaba-image":
return processWithAlibabaImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "ruxa-image":
return processWithRuxaImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "fal-image":
return processWithFalImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "openai-image":
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "gemini":
case "gemini-image":
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "volcano-vision":
return processWithVolcanoVision(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
default:
throw new Error(`不支持的模型类型: ${provider.type}`);
}
}
/**
* 使用阿里生图模型处理
*/
async function processWithAlibabaImage(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 计算尺寸
const size = calculateSize(aspectRatio || "auto", imageSize || "2K");
// 调用阿里生图 API
const generatedImageUrl = await callAlibabaImageAPI(
provider.baseUrl,
provider.apiKey,
modelId,
productImageUrl,
sceneImageUrl,
prompt,
size
);
// 下载并上传到 S3
const uploaded = await downloadAndUploadToS3(generatedImageUrl, "scene-fusion-alibaba");
return { ...uploaded, prompt };
}
/**
* 使用 Gemini 模型处理
*/
async function processWithGemini(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
console.log(`[SceneFusion][Gemini] Processing with model: ${modelId}`);
// 创建 Gemini provider
const geminiProvider = createGeminiImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
// 构建消息格式(Gemini 使用 OpenAI 兼容格式)
const messages = [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
];
// 调用 Gemini API
const result = await geminiProvider.chat(modelId).doGenerate({
messages,
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
});
// 解析返回的 Markdown 格式图片
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
/**
* 使用 RUXA 模型处理
*/
async function processWithRuxaImage(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
console.log(`[SceneFusion][RUXA] Processing with model: ${modelId}`);
const ruxaProvider = createRuxaImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const messages = [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
];
const result = await ruxaProvider.chat(modelId).doGenerate({
messages,
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
async function processWithFalImage(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
console.log(`[SceneFusion][FAL] Processing with model: ${modelId}`);
const falProvider = createFalImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const messages = [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
];
const result = await falProvider.chat(modelId).doGenerate({
messages,
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
async function processWithOpenAIImage(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
console.log(`[SceneFusion][OpenAI Image] Processing with model: ${modelId}`);
const openAIImageProvider = createOpenAIImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const messages = [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
];
const result = await openAIImageProvider.chat(modelId).doGenerate({
messages,
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
/**
* 使用既梦/火山模型处理
*/
async function processWithVolcanoVision(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
console.log(`[SceneFusion][Volcano] Processing with model: ${modelId}`);
// 创建 Volcano Vision provider
const volcanoProvider = createVolcanoVision({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
// 构建消息格式
const messages = [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
];
// 调用 Volcano Vision API
const result = await volcanoProvider.chat(modelId).doGenerate({
messages,
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
});
// 解析返回的 Markdown 格式图片
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
/**
* 从 Markdown 格式解析图片 URL
* 格式: ![thumb|||originalUrl|||size](thumbnailUrl)
*/
function parseImageFromMarkdown(markdown: string): { thumbnail: string; original: string } {
console.log(`[SceneFusion] Parsing markdown: ${markdown.substring(0, 200)}...`);
// 匹配 ![thumb|||originalUrl|||size](thumbnailUrl) 格式
const match = markdown.match(/!\[thumb\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/);
if (match) {
return {
original: match[1],
thumbnail: match[2],
};
}
// 尝试匹配普通图片格式 ![alt](url)
const simpleMatch = markdown.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (simpleMatch) {
return {
original: simpleMatch[1],
thumbnail: simpleMatch[1],
};
}
throw new Error("无法从响应中解析图片 URL");
}
/**
* 处理单个场景 (保留旧函数用于兼容)
*/
async function processScene(
productImageUrl: string,
sceneImageUrl: string,
productName: string | undefined,
targetName: string | undefined,
customPrompt: string | undefined,
defaultPrompt: string,
sceneMode: SceneFusionMode,
baseURL: string,
apiKey: string,
modelId: string,
size?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode);
console.log(`[SceneFusion] Built prompt: ${prompt}`);
// 调用阿里生图 API
const generatedImageUrl = await callAlibabaImageAPI(
baseURL,
apiKey,
modelId,
productImageUrl,
sceneImageUrl,
prompt,
size
);
// 下载并上传到 S3
const uploaded = await downloadAndUploadToS3(generatedImageUrl, "scene-fusion");
return { ...uploaded, prompt };
}
async function processSceneWithDiversity(
params: {
productImageUrl: string;
sceneImageUrl: string;
productName?: string;
targetName?: string;
customPrompt?: string;
defaultPrompt: string;
sceneMode: SceneFusionMode;
aspectRatio?: string;
imageSize?: string;
sceneIndex: number;
requestOffset: number;
requestId: string;
candidates: SceneFusionModelCandidate[];
imageHashCache: Map<string, string>;
}
): Promise<{
result: { thumbnail: string; original: string; prompt: string };
candidate: SceneFusionModelCandidate;
attempts: number;
}> {
const supportedCandidates = params.candidates.filter((candidate) =>
canProcessSceneFusionProviderType(candidate.provider.type)
);
if (supportedCandidates.length === 0) {
throw new Error("后台已配置生图模型中暂无可用于场景裂变的供应商,请检查模型供应商类型");
}
const rotatedCandidates = rotateCandidates(
supportedCandidates,
params.requestOffset + params.sceneIndex
);
pruneSceneFusionCooldown();
const healthyCandidates = rotatedCandidates.filter(
(candidate) => !isSceneFusionCandidateCoolingDown(candidate)
);
const attemptCandidates =
healthyCandidates.length > 0 ? healthyCandidates : rotatedCandidates;
console.log(
`[SceneFusion][${params.requestId}] Scene#${params.sceneIndex + 1} candidate order: ${attemptCandidates
.map((candidate) => `${candidate.model.displayName}/${candidate.provider.type}`)
.join(" -> ")}`
);
const failureReasons: string[] = [];
for (let attemptIndex = 0; attemptIndex < attemptCandidates.length; attemptIndex += 1) {
const candidate = attemptCandidates[attemptIndex]!;
try {
const normalizedOutputParams = normalizeSceneFusionOutputParamsForCandidate(candidate, {
aspectRatio: params.aspectRatio,
imageSize: params.imageSize,
});
if (normalizedOutputParams.changed) {
console.log(
`[SceneFusion][${params.requestId}] Candidate ${candidate.model.displayName} output params normalized: ${normalizedOutputParams.changes.join(", ")}`
);
}
const result = await processSceneWithProvider(
params.productImageUrl,
params.sceneImageUrl,
params.productName,
params.targetName,
params.customPrompt,
params.defaultPrompt,
params.sceneMode,
{
type: candidate.provider.type,
baseUrl: candidate.provider.baseUrl,
apiKey: candidate.provider.apiKey,
},
candidate.model.modelId,
normalizedOutputParams.aspectRatio,
normalizedOutputParams.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,
candidate,
attempts: attemptIndex + 1,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "未知错误";
failureReasons.push(`${candidate.model.displayName}: ${errorMessage}`);
markSceneFusionCandidateFailed(candidate);
console.error("[SceneFusion] Candidate failed, try next:", {
requestId: params.requestId,
sceneIndex: params.sceneIndex,
attempt: attemptIndex + 1,
model: candidate.model.modelId,
provider: candidate.provider.type,
error: errorMessage,
});
}
}
const reason = failureReasons.join(";");
throw new Error(
reason
? `多样性模式下所有可用模型均失败:${reason}`
: "多样性模式下所有可用模型均失败"
);
}
export async function POST(request: Request) {
try {
// 1. 认证
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未登录" },
{ status: 401 }
);
}
// 2. 获取场景裂变配置
const config = await getSceneFusionConfig();
if (!config.enabled) {
return NextResponse.json(
{ error: "场景裂变功能已被管理员禁用" },
{ status: 403 }
);
}
// 3. 解析请求体
const json = await request.json();
const parseResult = sceneFusionRequestSchema.safeParse(json);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors[0].message },
{ status: 400 }
);
}
const { productImage, productName, sceneMode, aspectRatio, imageSize, scenes } = parseResult.data;
const requestId = `sf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const resolvedSceneMode = sceneMode || "scene-fission";
// 计算输出尺寸
const size = calculateSize(aspectRatio || "auto", imageSize || "2K");
console.log(`[SceneFusion] Output settings: aspectRatio=${aspectRatio || 'auto'}, imageSize=${imageSize || '2K'}, calculated size=${size || 'auto'}`);
// 检查场景数量限制
const effectiveMaxScenes = Math.min(config.maxScenes, SCENE_FUSION_MAX_ITEMS);
if (scenes.length > effectiveMaxScenes) {
return NextResponse.json(
{ error: `最多只能添加 ${effectiveMaxScenes} 个场景` },
{ status: 400 }
);
}
console.log(`[SceneFusion] Processing ${scenes.length} scenes for user ${session.user.id}`);
console.log(`[SceneFusion] Product name: ${productName || '(not specified)'}`);
console.log(`[SceneFusion] Scene mode: ${resolvedSceneMode}`);
console.log(`[SceneFusion] Using default prompt: ${config.defaultPrompt.substring(0, 50)}...`);
console.log(`[SceneFusion] Preferred model ID: ${config.modelId || '(none)'}`);
// 4. 获取生图模型池(固定多样性模式)
const allModels = await getEnabledAiModelsWithProvider();
const candidateModels = buildSceneFusionCandidatePool(allModels, config.modelId);
if (config.modelId && !candidateModels.some((candidate) => candidate.model.id === config.modelId)) {
console.warn(
`[SceneFusion] Preferred model ${config.modelId} not available, fallback to full diversity pool`
);
}
if (candidateModels.length === 0) {
return NextResponse.json(
{ error: "未找到可用模型,请在后台启用至少一个支持生图的模型并配置供应商 API Key" },
{ status: 500 }
);
}
console.log(
`[SceneFusion] Diversity candidates: ${candidateModels
.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`)
.join(", ")}`
);
const unsupportedCandidates = candidateModels.filter(
(candidate) => !canProcessSceneFusionProviderType(candidate.provider.type)
);
if (unsupportedCandidates.length > 0) {
console.warn(
`[SceneFusion] Skip unsupported providers for scene fusion: ${unsupportedCandidates
.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`)
.join(", ")}`
);
}
const nonVisionCandidates = candidateModels.filter(
(candidate) => !candidate.model.supportsVision
);
if (nonVisionCandidates.length > 0) {
console.warn(
`[SceneFusion] Models without supportsVision are included for failover diversity: ${nonVisionCandidates
.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`)
.join(", ")}`
);
}
const requestOffset = getNextSceneFusionRoundRobinOffset(
Math.max(1, candidateModels.length)
);
console.log(
`[SceneFusion][${requestId}] Round-robin offset=${requestOffset}, scenes=${scenes.length}, candidates=${candidateModels.length}`
);
// 5. 预处理:将 Base64 图片上传到 S3
let productImageUrl = productImage;
if (productImage.startsWith("data:")) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
}
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg(
request,
requestId,
productImageUrl,
shouldForcePreRmbg ? true : config.preRmbgEnabled
);
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
console.log(
`[SceneFusion][${requestId}] Pre-RMBG result: used=${preprocessedProduct.usedPreRmbg}, provider=${preprocessedProduct.provider || "-"}, reason=${preprocessedProduct.reason || "-"}`
);
const processedScenes = await Promise.all(
scenes.map(async (scene) => {
let sceneImageUrl = scene.image;
if (scene.image.startsWith("data:")) {
sceneImageUrl = await uploadBase64ToS3(scene.image, "scene-fusion-scene");
}
return {
...scene,
imageUrl: sceneImageUrl,
};
})
);
const enriched = await enrichSceneLabelsWithAgent({
requestId,
productImageUrl: fusionProductImageUrl,
productName,
scenes: processedScenes,
});
const resolvedProductName = enriched.productName;
let resolvedScenes = enriched.scenes;
if (resolvedSceneMode === "random-bg") {
resolvedScenes = await enrichRandomBackgroundScenesWithAgent({
requestId,
productImageUrl: fusionProductImageUrl,
productName: resolvedProductName,
scenes: resolvedScenes,
});
}
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
.map((scene) => `${scene.id}:${scene.targetName || "-"}`)
.join(", ")}`
);
if (resolvedSceneMode === "random-bg") {
console.log(
`[SceneFusion][${requestId}] Random-bg prompts: ${resolvedScenes
.map((scene) => `${scene.id}:${(scene.customPrompt || "").slice(0, 80) || "-"}`)
.join(" | ")}`
);
}
// 6. 创建流式响应,每完成一张图就立即发送给前端
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let streamClosed = false;
const safeClose = () => {
if (streamClosed) return;
streamClosed = true;
try {
controller.close();
} catch (closeError) {
console.warn("[SceneFusion] Stream close skipped:", closeError);
}
};
const enqueue = (payload: Record<string, unknown>) => {
if (streamClosed) return false;
try {
controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`));
return true;
} catch (enqueueError) {
streamClosed = true;
console.warn("[SceneFusion] Stream enqueue skipped:", enqueueError);
return false;
}
};
try {
// 发送初始消息
if (!enqueue({ type: "start", total: processedScenes.length })) {
return;
}
enqueue({
type: "agent_meta",
data: {
productName: enriched.meta.productName || resolvedProductName || "",
productSource: enriched.meta.productSource || (resolvedProductName ? "agent" : "none"),
sceneTargets: enriched.meta.sceneTargets,
},
});
let successCount = 0;
let failCount = 0;
let nextSceneIndex = 0;
const workerCount = Math.max(
1,
Math.min(
SCENE_FUSION_PROCESS_MAX_WORKERS,
candidateModels.length,
processedScenes.length
)
);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
if (streamClosed) break;
const currentIndex = nextSceneIndex;
nextSceneIndex += 1;
if (currentIndex >= processedScenes.length) break;
const staggerDelay = (currentIndex % workerCount) * SCENE_FUSION_REQUEST_STAGGER_MS;
if (staggerDelay > 0) {
await waitSceneFusion(staggerDelay);
}
const scene = processedScenes[currentIndex];
if (!scene) break;
const resolvedScene = resolvedSceneMap.get(scene.id) || scene;
console.log(
`[SceneFusion] ========== Processing scene ${currentIndex + 1}/${processedScenes.length} ==========`
);
console.log(`[SceneFusion] Scene ID: ${scene.id}`);
console.log(
`[SceneFusion] Product Image URL: ${fusionProductImageUrl.substring(0, 80)}...`
);
console.log(
`[SceneFusion] Scene Image URL: ${scene.imageUrl.substring(0, 80)}...`
);
console.log(
`[SceneFusion] Target Name: ${resolvedScene.targetName || "(not specified)"}`
);
console.log(
`[SceneFusion] Custom Prompt: ${resolvedScene.customPrompt || "(none)"}`
);
try {
const { result, candidate, attempts } = await processSceneWithDiversity({
productImageUrl: fusionProductImageUrl,
sceneImageUrl: resolvedScene.imageUrl,
productName: resolvedProductName,
targetName: resolvedScene.targetName,
customPrompt: resolvedScene.customPrompt,
defaultPrompt: config.defaultPrompt,
sceneMode: resolvedSceneMode,
aspectRatio,
imageSize,
sceneIndex: currentIndex,
requestOffset,
requestId,
candidates: candidateModels,
imageHashCache,
});
const dbImageSize = imageSize || "2K";
await saveSceneFusionImageRecord({
userId: session.user.id,
thumbnailUrl: result.thumbnail,
originalUrl: result.original,
imageSize: dbImageSize,
prompt: `[场景裂变] ${result.prompt}`,
modelId: candidate.model.modelId,
sourceImageUrls: [fusionProductImageUrl, resolvedScene.imageUrl],
});
console.log(
`[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...`
);
successCount += 1;
if (!enqueue({
type: "result",
data: {
id: scene.id,
status: "completed" as const,
result: result.thumbnail,
originalUrl: result.original,
model: candidate.model.displayName,
modelId: candidate.model.modelId,
providerType: candidate.provider.type,
attempts,
recognizedProductName: resolvedProductName,
recognizedTargetName: resolvedScene.targetName,
},
})) {
break;
}
} catch (error) {
console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error);
failCount += 1;
if (!enqueue({
type: "result",
data: {
id: scene.id,
status: "failed" as const,
error: error instanceof Error ? error.message : "处理失败",
},
})) {
break;
}
}
}
});
await Promise.all(workers);
// 发送完成消息
console.log(`[SceneFusion] Completed: ${successCount} success, ${failCount} failed`);
enqueue({
type: "done",
summary: {
total: processedScenes.length,
success: successCount,
failed: failCount,
},
});
} catch (error) {
console.error("[SceneFusion] Stream error:", error);
// 发送错误消息
if (!streamClosed) {
enqueue({
type: "error",
error: error instanceof Error ? error.message : "处理失败",
});
}
} finally {
safeClose();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} catch (error) {
console.error("[SceneFusion] Unexpected error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "处理失败" },
{ status: 500 }
);
}
}