904 lines
29 KiB
TypeScript
904 lines
29 KiB
TypeScript
/**
|
||
* 场景裂变 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 { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||
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)注意光影和透视关系使融合自然。";
|
||
|
||
/**
|
||
* 获取场景裂变配置
|
||
*/
|
||
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":
|
||
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
|
||
* 格式: 
|
||
*/
|
||
function parseImageFromMarkdown(markdown: string): { thumbnail: string; original: string } {
|
||
console.log(`[SceneFusion] Parsing markdown: ${markdown.substring(0, 200)}...`);
|
||
|
||
// 匹配  格式
|
||
const match = markdown.match(/!\[thumb\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/);
|
||
if (match) {
|
||
return {
|
||
original: match[1],
|
||
thumbnail: match[2],
|
||
};
|
||
}
|
||
|
||
// 尝试匹配普通图片格式 
|
||
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 };
|
||
}
|
||
|
||
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] Configured model ID: ${config.modelId || '(auto - default alibaba)'}`);
|
||
|
||
// 4. 获取生图模型配置
|
||
const allModels = await getEnabledAiModelsWithProvider();
|
||
|
||
let selectedModel: typeof allModels[0] | undefined;
|
||
|
||
// 如果配置了指定的模型ID,优先使用
|
||
if (config.modelId) {
|
||
selectedModel = allModels.find(
|
||
(m) => m.model.id === config.modelId && m.model.isEnabled
|
||
);
|
||
if (!selectedModel) {
|
||
console.warn(`[SceneFusion] Configured model ${config.modelId} not found or disabled, falling back to default`);
|
||
}
|
||
}
|
||
|
||
// 如果没有配置或配置的模型不可用,使用默认的阿里生图模型
|
||
if (!selectedModel) {
|
||
// 按优先级查找:alibaba-image > ruxa-image > fal-image > gemini > volcano-vision
|
||
const imageProviderTypes = ["alibaba-image", "ruxa-image", "fal-image", "gemini", "volcano-vision"];
|
||
for (const providerType of imageProviderTypes) {
|
||
selectedModel = allModels.find(
|
||
(m) => m.provider.type === providerType && m.model.isEnabled
|
||
);
|
||
if (selectedModel) break;
|
||
}
|
||
}
|
||
|
||
if (!selectedModel) {
|
||
return NextResponse.json(
|
||
{ error: "未配置可用的生图模型,请联系管理员" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
const { provider, model } = selectedModel;
|
||
console.log(`[SceneFusion] Using model: ${model.displayName} (${model.modelId}) - Provider: ${provider.type}`);
|
||
|
||
// 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 = [];
|
||
|
||
// 6. 创建流式响应,每完成一张图就立即发送给前端
|
||
const stream = new ReadableStream({
|
||
async start(controller) {
|
||
const encoder = new TextEncoder();
|
||
|
||
try {
|
||
// 发送初始消息
|
||
controller.enqueue(encoder.encode(JSON.stringify({ type: "start", total: processedScenes.length }) + "\n"));
|
||
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
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)'}`);
|
||
console.log(`[SceneFusion] Provider Type: ${provider.type}`);
|
||
|
||
// 如果不是第一张图,等待5秒
|
||
if (i > 0) {
|
||
console.log(`[SceneFusion] Waiting 5 seconds before processing...`);
|
||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||
}
|
||
|
||
try {
|
||
// 使用新的通用处理函数,支持多种模型类型
|
||
const result = await processSceneWithProvider(
|
||
productImageUrl,
|
||
scene.imageUrl,
|
||
productName,
|
||
scene.targetName,
|
||
scene.customPrompt,
|
||
config.defaultPrompt,
|
||
{ type: provider.type, baseUrl: provider.baseUrl, apiKey: provider.apiKey },
|
||
model.modelId,
|
||
aspectRatio,
|
||
imageSize
|
||
);
|
||
|
||
// 将分辨率等级用于数据库存储
|
||
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);
|
||
}
|
||
|
||
successCount++;
|
||
|
||
// 立即发送完成的结果给前端
|
||
controller.enqueue(encoder.encode(JSON.stringify({
|
||
type: "result",
|
||
data: {
|
||
id: scene.id,
|
||
status: "completed" as const,
|
||
result: result.thumbnail, // 返回缩略图用于预览
|
||
originalUrl: result.original, // 原图用于下载
|
||
},
|
||
}) + "\n"));
|
||
|
||
} catch (error) {
|
||
console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error);
|
||
failCount++;
|
||
|
||
// 立即发送失败的结果给前端
|
||
controller.enqueue(encoder.encode(JSON.stringify({
|
||
type: "result",
|
||
data: {
|
||
id: scene.id,
|
||
status: "failed" as const,
|
||
error: error instanceof Error ? error.message : "处理失败",
|
||
},
|
||
}) + "\n"));
|
||
}
|
||
}
|
||
|
||
// 发送完成消息
|
||
console.log(`[SceneFusion] Completed: ${successCount} success, ${failCount} failed`);
|
||
controller.enqueue(encoder.encode(JSON.stringify({
|
||
type: "done",
|
||
summary: {
|
||
total: processedScenes.length,
|
||
success: successCount,
|
||
failed: failCount,
|
||
},
|
||
}) + "\n"));
|
||
|
||
} catch (error) {
|
||
console.error("[SceneFusion] Stream error:", error);
|
||
// 发送错误消息
|
||
controller.enqueue(encoder.encode(JSON.stringify({
|
||
type: "error",
|
||
error: error instanceof Error ? error.message : "处理失败",
|
||
}) + "\n"));
|
||
} 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 }
|
||
);
|
||
}
|
||
}
|