/** * 场景裂变 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(); 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(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(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>, 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 { 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 { 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 = { "1K": 1024, "2K": 1536, "4K": 2048, }; const baseSize = baseSizeMap[imageSize] || 1536; // 根据比例计算宽高 const ratioMap: Record = { "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 { // 构建消息内容:产品图 + 场景图 + 提示词 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) => { 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 } ); } }