/** * 场景裂变 API * * 将一张产品图批量融合到多个场景图中 * 使用阿里云千问图片编辑模型 */ 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"; // 请求体 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的场景中。禁止对产品进行任何变形、拉伸、压缩或颜色改变。产品必须与图1完全一致,只改变其所处的环境背景。"; /** * 获取场景裂变配置 */ 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; }; return { enabled: config.enabled ?? true, defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT, maxScenes: config.maxScenes ?? 10, }; } } catch (error) { console.error("[SceneFusion] Failed to load config:", error); } return { enabled: true, defaultPrompt: FALLBACK_PROMPT, maxScenes: 10, }; } /** * 创建缩略图 */ 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(); 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) { throw new Error("No images in response"); } const imageContent = choices[0]?.message?.content; if (!imageContent || imageContent.length === 0) { throw new Error("No image content in response"); } const generatedImageUrl = imageContent[0]?.image; if (!generatedImageUrl) { throw new Error("No image URL in response"); } 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; // 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词 // 所有模板都强调:严格保持产品原样,禁止变形 if (productName && targetName) { prompt = `严格按照图1中"${productName}"的原始形状、尺寸比例、颜色、材质和所有细节,将其完整地放置到图2场景中,替换掉图2中的"${targetName}"。禁止对"${productName}"进行任何变形、拉伸、压缩或颜色改变。"${productName}"必须与图1完全一致。`; } else if (productName) { prompt = `严格按照图1中"${productName}"的原始形状、尺寸比例、颜色、材质和所有细节,将其完整地放置到图2的场景中。禁止对"${productName}"进行任何变形、拉伸、压缩或颜色改变。"${productName}"必须与图1完全一致。`; } else if (targetName) { prompt = `严格按照图1产品的原始形状、尺寸比例、颜色、材质和所有细节,将其完整地放置到图2场景中,替换掉图2中的"${targetName}"。禁止对产品进行任何变形、拉伸、压缩或颜色改变。产品必须与图1完全一致。`; } // 添加用户补充的关键词 if (customPrompt) { prompt = `${prompt} ${customPrompt}`; } return prompt; } /** * 处理单个场景 */ 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 }; } 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)}...`); // 4. 获取阿里生图模型配置 const allModels = await getEnabledAiModelsWithProvider(); const alibabaModel = allModels.find( (m) => m.provider.type === "alibaba-image" && m.model.isEnabled ); if (!alibabaModel) { return NextResponse.json( { error: "未配置阿里生图模型,请联系管理员" }, { status: 500 } ); } const { provider, model } = alibabaModel; console.log(`[SceneFusion] Using model: ${model.displayName} (${model.modelId})`); // 4. 预处理:将 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. 串行处理所有场景,每次间隔5秒 const results = []; for (let i = 0; i < processedScenes.length; i++) { const scene = processedScenes[i]; console.log(`[SceneFusion] ========== Processing scene ${i + 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)'}`); // 如果不是第一张图,等待5秒 if (i > 0) { console.log(`[SceneFusion] Waiting 5 seconds before processing...`); await new Promise((resolve) => setTimeout(resolve, 5000)); } try { const result = await processScene( productImageUrl, scene.imageUrl, productName, scene.targetName, scene.customPrompt, config.defaultPrompt, provider.baseUrl, provider.apiKey, model.modelId, size ); // 将分辨率等级用于数据库存储 const dbImageSize = imageSize || "2K"; // 保存到数据库(chatId 和 messageId 设为 null,因为场景裂变不关联聊天) 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: 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); } results.push({ id: scene.id, status: "completed" as const, result: result.thumbnail, // 返回缩略图用于预览 originalUrl: result.original, // 原图用于下载 }); } catch (error) { console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error); results.push({ id: scene.id, status: "failed" as const, error: error instanceof Error ? error.message : "处理失败", }); } } // 6. 返回结果 const successCount = results.filter((r) => r.status === "completed").length; const failCount = results.filter((r) => r.status === "failed").length; console.log(`[SceneFusion] Completed: ${successCount} success, ${failCount} failed`); return NextResponse.json({ success: true, results, summary: { total: scenes.length, success: successCount, failed: failCount, }, }); } catch (error) { console.error("[SceneFusion] Unexpected error:", error); return NextResponse.json( { error: error instanceof Error ? error.message : "处理失败" }, { status: 500 } ); } }