Your Name cd09eb9004
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(scene-fusion): enforce diversity mode from configured tagged models
2026-03-04 20:42:38 +08:00

1127 lines
36 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 { getEnabledAiModelsWithProvider, getSystemSetting } 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";
// 请求体 Schema
const sceneFusionRequestSchema = z.object({
productImage: z.string().min(1, "产品图不能为空"),
productName: z.string().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(20, "最多支持20个场景图"),
});
// 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式
const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。";
const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000;
const sceneFusionModelCooldown = new Map<string, number>();
interface SceneFusionModelCandidate {
model: {
id: string;
modelId: string;
displayName: string;
};
provider: {
type: string;
baseUrl: string;
apiKey: string;
};
}
function normalizeSceneFusionProviderType(providerType: string): string {
return providerType === "gemini-image" ? "gemini" : providerType;
}
function canProcessSceneFusionProviderType(providerType: string): boolean {
const normalized = normalizeSceneFusionProviderType(providerType);
switch (normalized) {
case "alibaba-image":
case "ruxa-image":
case "fal-image":
case "gemini":
case "volcano-vision":
return true;
default:
return false;
}
}
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 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?.supportsVision) 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),
},
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;
};
return {
enabled: config.enabled ?? true,
defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT,
maxScenes: config.maxScenes ?? 10,
modelId: config.modelId || "",
};
}
} catch (error) {
console.error("[SceneFusion] Failed to load config:", error);
}
return {
enabled: true,
defaultPrompt: FALLBACK_PROMPT,
maxScenes: 10,
modelId: "",
};
}
/**
* 创建缩略图
*/
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;
}
}
/**
* 根据 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
): string {
let prompt = defaultPrompt;
// 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词
// 使用"参考图1"的表述方式,并强调产品的视觉特征
if (productName && targetName) {
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}`;
}
return prompt;
}
/**
* 通用场景处理函数 - 根据 provider 类型调用不同的实现
*/
async function processSceneWithProvider(
productImageUrl: string,
sceneImageUrl: string,
productName: string | undefined,
targetName: string | undefined,
customPrompt: string | undefined,
defaultPrompt: string,
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);
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 "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 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,
baseURL: string,
apiKey: string,
modelId: string,
size?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt);
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;
aspectRatio?: string;
imageSize?: string;
sceneIndex: number;
candidates: SceneFusionModelCandidate[];
}
): 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.sceneIndex);
pruneSceneFusionCooldown();
const healthyCandidates = rotatedCandidates.filter(
(candidate) => !isSceneFusionCandidateCoolingDown(candidate)
);
const attemptCandidates =
healthyCandidates.length > 0 ? healthyCandidates : rotatedCandidates;
const failureReasons: string[] = [];
for (let attemptIndex = 0; attemptIndex < attemptCandidates.length; attemptIndex += 1) {
const candidate = attemptCandidates[attemptIndex]!;
try {
const result = await processSceneWithProvider(
params.productImageUrl,
params.sceneImageUrl,
params.productName,
params.targetName,
params.customPrompt,
params.defaultPrompt,
{
type: candidate.provider.type,
baseUrl: candidate.provider.baseUrl,
apiKey: candidate.provider.apiKey,
},
candidate.model.modelId,
params.aspectRatio,
params.imageSize
);
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:", {
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, aspectRatio, imageSize, scenes } = parseResult.data;
// 计算输出尺寸
const size = calculateSize(aspectRatio || "auto", imageSize || "2K");
console.log(`[SceneFusion] Output settings: aspectRatio=${aspectRatio || 'auto'}, imageSize=${imageSize || '2K'}, calculated size=${size || 'auto'}`);
// 检查场景数量限制
if (scenes.length > config.maxScenes) {
return NextResponse.json(
{ error: `最多只能添加 ${config.maxScenes} 个场景` },
{ 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] 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: "未找到可用模型,请在后台将模型同时标记为“支持生图”和“支持图片输入(vision)”并启用" },
{ 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(", ")}`
);
}
// 5. 预处理:将 Base64 图片上传到 S3
let productImageUrl = productImage;
if (productImage.startsWith("data:")) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
}
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,
};
})
);
// 6. 创建流式响应,每完成一张图就立即发送给前端
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const enqueue = (payload: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`));
};
try {
// 发送初始消息
enqueue({ type: "start", total: processedScenes.length });
let successCount = 0;
let failCount = 0;
let nextSceneIndex = 0;
const workerCount = Math.max(
1,
Math.min(4, candidateModels.length, processedScenes.length)
);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const currentIndex = nextSceneIndex;
nextSceneIndex += 1;
if (currentIndex >= processedScenes.length) break;
const scene = processedScenes[currentIndex];
console.log(
`[SceneFusion] ========== Processing scene ${currentIndex + 1}/${processedScenes.length} ==========`
);
console.log(`[SceneFusion] Scene ID: ${scene.id}`);
console.log(
`[SceneFusion] Product Image URL: ${productImageUrl.substring(0, 80)}...`
);
console.log(
`[SceneFusion] Scene Image URL: ${scene.imageUrl.substring(0, 80)}...`
);
console.log(
`[SceneFusion] Target Name: ${scene.targetName || "(not specified)"}`
);
console.log(
`[SceneFusion] Custom Prompt: ${scene.customPrompt || "(none)"}`
);
try {
const { result, candidate, attempts } = await processSceneWithDiversity({
productImageUrl,
sceneImageUrl: scene.imageUrl,
productName,
targetName: scene.targetName,
customPrompt: scene.customPrompt,
defaultPrompt: config.defaultPrompt,
aspectRatio,
imageSize,
sceneIndex: currentIndex,
candidates: candidateModels,
});
const dbImageSize = imageSize || "2K";
try {
await db.insert(generatedImage).values({
userId: session.user.id,
chatId: null,
messageId: null,
thumbnailUrl: result.thumbnail,
originalUrl: result.original,
imageSize: dbImageSize,
prompt: `[场景裂变] ${result.prompt}`,
modelId: candidate.model.modelId,
});
console.log(
`[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...`
);
} catch (dbError) {
console.error("[SceneFusion] ❌ Failed to save to gallery:", dbError);
}
successCount += 1;
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,
},
});
} catch (error) {
console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error);
failCount += 1;
enqueue({
type: "result",
data: {
id: scene.id,
status: "failed" as const,
error: error instanceof Error ? error.message : "处理失败",
},
});
}
}
});
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);
// 发送错误消息
enqueue({
type: "error",
error: error instanceof Error ? error.message : "处理失败",
});
} finally {
controller.close();
}
},
});
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 }
);
}
}