525 lines
17 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
*
* 将一张产品图批量融合到多个场景图中
* 使用阿里云千问图片编辑模型
*/
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<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();
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 }
);
}
}