功能一:图片编辑器入口优化 - 支持先打开编辑器再上传图片 - 新增拖拽上传功能 - 新增粘贴上传功能 (Ctrl+V) 功能二:场景裂变 - 新增场景裂变弹窗组件 - 支持产品图批量融合到多个场景 - 每个场景独立配置补充关键词 - 并发处理提高效率 - 支持单个重新生成和全部下载 - 生成结果自动保存到相册 管理员配置: - 新增场景裂变配置面板 - 支持自定义默认提示词 - 支持配置最大场景数量 - 支持功能开关 新增文件: - app/api/scene-fusion/route.ts - app/api/scene-fusion/config/route.ts - components/scene-fusion-dialog.tsx - docs/图片编辑器功能升级规划.md
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
/**
|
||
* 场景裂变 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, "产品图不能为空"),
|
||
scenes: z.array(z.object({
|
||
id: z.string(),
|
||
image: z.string().min(1, "场景图不能为空"),
|
||
customPrompt: z.string().optional(),
|
||
})).min(1, "至少需要一个场景图").max(20, "最多支持20个场景图"),
|
||
});
|
||
|
||
// 默认提示词(当数据库未配置时使用)
|
||
const FALLBACK_PROMPT = "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||
|
||
/**
|
||
* 获取场景裂变配置
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调用阿里生图 API
|
||
*/
|
||
async function callAlibabaImageAPI(
|
||
baseURL: string,
|
||
apiKey: string,
|
||
modelId: string,
|
||
productImageUrl: string,
|
||
sceneImageUrl: string,
|
||
prompt: string
|
||
): Promise<string> {
|
||
// 构建消息内容:产品图 + 场景图 + 提示词
|
||
const content = [
|
||
{ image: productImageUrl },
|
||
{ image: sceneImageUrl },
|
||
{ text: prompt },
|
||
];
|
||
|
||
const requestBody = {
|
||
model: modelId,
|
||
input: {
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content,
|
||
},
|
||
],
|
||
},
|
||
parameters: {
|
||
n: 1,
|
||
prompt_extend: true,
|
||
watermark: false,
|
||
},
|
||
};
|
||
|
||
console.log("[SceneFusion] Sending request to Alibaba API...");
|
||
|
||
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:", data);
|
||
throw new Error(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;
|
||
}
|
||
|
||
/**
|
||
* 处理单个场景
|
||
*/
|
||
async function processScene(
|
||
productImageUrl: string,
|
||
sceneImageUrl: string,
|
||
customPrompt: string | undefined,
|
||
defaultPrompt: string,
|
||
baseURL: string,
|
||
apiKey: string,
|
||
modelId: string
|
||
): Promise<{ thumbnail: string; original: string }> {
|
||
// 组合提示词
|
||
const prompt = customPrompt
|
||
? `${defaultPrompt} ${customPrompt}`
|
||
: defaultPrompt;
|
||
|
||
// 调用阿里生图 API
|
||
const generatedImageUrl = await callAlibabaImageAPI(
|
||
baseURL,
|
||
apiKey,
|
||
modelId,
|
||
productImageUrl,
|
||
sceneImageUrl,
|
||
prompt
|
||
);
|
||
|
||
// 下载并上传到 S3
|
||
const uploaded = await downloadAndUploadToS3(generatedImageUrl, "scene-fusion");
|
||
|
||
return uploaded;
|
||
}
|
||
|
||
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, scenes } = parseResult.data;
|
||
|
||
// 检查场景数量限制
|
||
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] Using 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. 并发处理所有场景
|
||
const results = await Promise.all(
|
||
processedScenes.map(async (scene) => {
|
||
try {
|
||
const result = await processScene(
|
||
productImageUrl,
|
||
scene.imageUrl,
|
||
scene.customPrompt,
|
||
config.defaultPrompt,
|
||
provider.baseUrl,
|
||
provider.apiKey,
|
||
model.modelId
|
||
);
|
||
|
||
// 保存到数据库
|
||
const messageId = `scene-fusion-${Date.now()}-${scene.id}`;
|
||
const chatId = `scene-fusion-batch-${Date.now()}`;
|
||
|
||
try {
|
||
await db.insert(generatedImage).values({
|
||
userId: session.user.id,
|
||
chatId,
|
||
messageId,
|
||
thumbnailUrl: result.thumbnail,
|
||
originalUrl: result.original,
|
||
imageSize: "2K",
|
||
prompt: scene.customPrompt
|
||
? `${config.defaultPrompt} ${scene.customPrompt}`
|
||
: config.defaultPrompt,
|
||
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);
|
||
}
|
||
|
||
return {
|
||
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);
|
||
return {
|
||
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 }
|
||
);
|
||
}
|
||
}
|