300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { getSystemSetting } from "@/lib/db/queries";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
import { db } from "@/lib/db";
|
||
import { generatedImage } from "@/lib/db/schema";
|
||
|
||
// 角度编辑API配置类型
|
||
type AngleEditConfig = {
|
||
enabled?: boolean;
|
||
falApiKey?: string;
|
||
loraScale?: number;
|
||
loraUrl?: string;
|
||
inferenceSteps?: number;
|
||
guidanceScale?: number;
|
||
maxImageSize?: number;
|
||
timeout?: number;
|
||
};
|
||
|
||
// 角度预设配置(使用官方提示词格式)
|
||
const ANGLE_PRESETS: Record<string, { azimuth: string; elevation: string; distance: string }> = {
|
||
// 汽车常用角度
|
||
"front": { azimuth: "front view", elevation: "eye-level shot", distance: "medium shot" },
|
||
"front-3/4": { azimuth: "front-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
|
||
"side": { azimuth: "right side view", elevation: "eye-level shot", distance: "medium shot" },
|
||
"rear-3/4": { azimuth: "back-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
|
||
"rear": { azimuth: "back view", elevation: "eye-level shot", distance: "medium shot" },
|
||
"overhead": { azimuth: "front view", elevation: "high-angle shot", distance: "wide shot" },
|
||
"low-front": { azimuth: "front view", elevation: "low-angle shot", distance: "close-up" },
|
||
"low-side": { azimuth: "right side view", elevation: "low-angle shot", distance: "medium shot" },
|
||
};
|
||
|
||
// 方位角映射(官方格式)
|
||
const AZIMUTH_MAP: Record<string, string> = {
|
||
"front": "front view",
|
||
"front-right": "front-right quarter view",
|
||
"right": "right side view",
|
||
"back-right": "back-right quarter view",
|
||
"back": "back view",
|
||
"back-left": "back-left quarter view",
|
||
"left": "left side view",
|
||
"front-left": "front-left quarter view",
|
||
};
|
||
|
||
// 仰角映射(官方格式)
|
||
const ELEVATION_MAP: Record<string, string> = {
|
||
"high": "high-angle shot", // 60°
|
||
"elevated": "elevated shot", // 30°
|
||
"eye-level": "eye-level shot", // 0°
|
||
"low": "low-angle shot", // -30°
|
||
};
|
||
|
||
// 距离映射(官方格式)
|
||
const DISTANCE_MAP: Record<string, string> = {
|
||
"close": "close-up", // ×0.6
|
||
"medium": "medium shot", // ×1.0
|
||
"wide": "wide shot", // ×1.8
|
||
};
|
||
|
||
// 默认 LoRA 地址
|
||
const DEFAULT_LORA_URL =
|
||
"https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors";
|
||
|
||
// 设置API路由最大执行时间为3分钟(180秒)
|
||
export const maxDuration = 180;
|
||
|
||
/**
|
||
* 压缩图片生成缩略图
|
||
*/
|
||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||
try {
|
||
const sharp = await import("sharp");
|
||
|
||
const thumbnailBuffer = await sharp.default(buffer)
|
||
.resize(500, null, {
|
||
fit: "inside",
|
||
withoutEnlargement: true,
|
||
})
|
||
.jpeg({ quality: 80 })
|
||
.toBuffer();
|
||
|
||
console.log(`[Angle-Edit] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||
return thumbnailBuffer;
|
||
} catch (error) {
|
||
console.error("[Angle-Edit] Failed to create thumbnail:", error);
|
||
return buffer;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将 base64 图片上传到 S3
|
||
*/
|
||
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
|
||
try {
|
||
const buffer = Buffer.from(base64Data, "base64");
|
||
const timestamp = Date.now();
|
||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
|
||
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
|
||
|
||
console.log(`[Angle-Edit] Original size: ${buffer.length} bytes`);
|
||
|
||
const thumbnailBuffer = await createThumbnail(buffer);
|
||
|
||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||
thumbnailBuffer.byteOffset,
|
||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||
) as ArrayBuffer;
|
||
const originalArrayBuffer = buffer.buffer.slice(
|
||
buffer.byteOffset,
|
||
buffer.byteOffset + buffer.byteLength
|
||
) as ArrayBuffer;
|
||
|
||
console.log(`[Angle-Edit] Uploading thumbnail and original to S3...`);
|
||
const [thumbnailResult, originalResult] = await Promise.all([
|
||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||
uploadFile(originalFilename, originalArrayBuffer),
|
||
]);
|
||
|
||
console.log(`[Angle-Edit] Uploaded to S3:`);
|
||
console.log(` - Thumbnail: ${thumbnailResult.url}`);
|
||
console.log(` - Original: ${originalResult.url}`);
|
||
|
||
return {
|
||
thumbnail: thumbnailResult.url,
|
||
original: originalResult.url,
|
||
};
|
||
} catch (error) {
|
||
console.error("[Angle-Edit] Failed to upload to S3:", error);
|
||
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
|
||
}
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
// 1. 验证用户登录
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
console.log("[Angle-Edit] ===== 收到请求 =====");
|
||
console.log("[Angle-Edit] 用户ID:", session.user.id);
|
||
|
||
// 2. 从数据库获取配置
|
||
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
|
||
const config = (angleEditConfig?.value as AngleEditConfig) || {};
|
||
|
||
// 验证功能是否启用
|
||
if (!config.enabled) {
|
||
return NextResponse.json(
|
||
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 验证 API Key
|
||
if (!config.falApiKey) {
|
||
return NextResponse.json(
|
||
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
console.log("[Angle-Edit] 配置加载成功,LoRA强度:", config.loraScale || 0.9);
|
||
|
||
// 3. 解析请求
|
||
const body = await request.json();
|
||
const {
|
||
image_url,
|
||
image_base64,
|
||
preset,
|
||
azimuth,
|
||
elevation,
|
||
distance,
|
||
lora_scale,
|
||
} = body;
|
||
|
||
if (!image_url && !image_base64) {
|
||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||
}
|
||
|
||
// 使用请求中的 lora_scale 或配置中的默认值
|
||
const finalLoraScale = lora_scale ?? config.loraScale ?? 0.9;
|
||
|
||
// 4. 构建角度参数
|
||
let angleParams: { azimuth: string; elevation: string; distance: string };
|
||
|
||
if (preset && ANGLE_PRESETS[preset]) {
|
||
angleParams = ANGLE_PRESETS[preset];
|
||
console.log("[Angle-Edit] 使用预设:", preset);
|
||
} else {
|
||
angleParams = {
|
||
azimuth: AZIMUTH_MAP[azimuth] || azimuth || "front view",
|
||
elevation: ELEVATION_MAP[elevation] || elevation || "eye-level shot",
|
||
distance: DISTANCE_MAP[distance] || distance || "medium shot",
|
||
};
|
||
console.log("[Angle-Edit] 使用自定义参数:", angleParams);
|
||
}
|
||
|
||
// 5. 构建提示词
|
||
const prompt = `<sks> ${angleParams.azimuth} ${angleParams.elevation} ${angleParams.distance}`;
|
||
console.log("[Angle-Edit] 提示词:", prompt);
|
||
|
||
// 6. 准备图片输入
|
||
let imageInput: string;
|
||
if (image_url) {
|
||
imageInput = image_url;
|
||
} else {
|
||
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||
imageInput = `data:${mimeType};base64,${image_base64}`;
|
||
}
|
||
|
||
// 7. 动态导入 fal.ai SDK 并调用 API
|
||
console.log("[Angle-Edit] 调用 fal.ai API...");
|
||
|
||
const fal = await import("@fal-ai/serverless-client");
|
||
|
||
fal.config({
|
||
credentials: config.falApiKey,
|
||
});
|
||
|
||
const loraUrl = config.loraUrl || DEFAULT_LORA_URL;
|
||
|
||
const result = await fal.subscribe("fal-ai/qwen-image-edit", {
|
||
input: {
|
||
image_url: imageInput,
|
||
prompt: prompt,
|
||
lora_url: loraUrl,
|
||
lora_scale: finalLoraScale,
|
||
num_inference_steps: config.inferenceSteps || 28,
|
||
guidance_scale: config.guidanceScale || 3.5,
|
||
},
|
||
logs: true,
|
||
onQueueUpdate: (update) => {
|
||
if (update.status === "IN_PROGRESS") {
|
||
console.log("[Angle-Edit] 处理进度...");
|
||
}
|
||
},
|
||
});
|
||
|
||
// 8. 处理结果
|
||
const outputImage = (result as { images?: Array<{ url?: string }> }).images?.[0];
|
||
if (!outputImage?.url) {
|
||
throw new Error("API 未返回有效图片");
|
||
}
|
||
|
||
const outputImageUrl = outputImage.url;
|
||
console.log("[Angle-Edit] 输出图片URL:", outputImageUrl);
|
||
|
||
// 9. 下载图片并上传到我们的 S3
|
||
const imageResponse = await fetch(outputImageUrl);
|
||
if (!imageResponse.ok) {
|
||
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
|
||
}
|
||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||
const imageBase64Result = imageBuffer.toString("base64");
|
||
|
||
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
|
||
console.log("[Angle-Edit] 已上传到S3:", s3Result.original);
|
||
|
||
// 10. 保存到数据库
|
||
const processingTime = (Date.now() - startTime) / 1000;
|
||
|
||
await db.insert(generatedImage).values({
|
||
userId: session.user.id,
|
||
thumbnailUrl: s3Result.thumbnail,
|
||
originalUrl: s3Result.original,
|
||
prompt: `角度编辑: ${preset || `${azimuth}-${elevation}-${distance}`}`,
|
||
modelId: "qwen-image-edit-angle",
|
||
createdAt: new Date(),
|
||
});
|
||
|
||
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
|
||
|
||
// 11. 返回结果
|
||
return NextResponse.json({
|
||
success: true,
|
||
image_url: s3Result.original,
|
||
thumbnail_url: s3Result.thumbnail,
|
||
image_base64: imageBase64Result,
|
||
processing_time: processingTime,
|
||
preset: preset,
|
||
angles: angleParams,
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("[Angle-Edit] ❌ 错误:", error);
|
||
return NextResponse.json(
|
||
{
|
||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|