Your Name 688f67c25c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 图片编辑器升级 - 入口优化 + 场景裂变功能
功能一:图片编辑器入口优化
- 支持先打开编辑器再上传图片
- 新增拖拽上传功能
- 新增粘贴上传功能 (Ctrl+V)

功能二:场景裂变
- 新增场景裂变弹窗组件
- 支持产品图批量融合到多个场景
- 每个场景独立配置补充关键词
- 并发处理提高效率
- 支持单个重新生成和全部下载
- 生成结果自动保存到相册

管理员配置:
- 新增场景裂变配置面板
- 支持自定义默认提示词
- 支持配置最大场景数量
- 支持功能开关

新增文件:
- app/api/scene-fusion/route.ts
- app/api/scene-fusion/config/route.ts
- components/scene-fusion-dialog.tsx
- docs/图片编辑器功能升级规划.md
2026-01-18 14:56:21 +08:00

407 lines
12 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, "产品图不能为空"),
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 }
);
}
}