274 lines
9.2 KiB
TypeScript
274 lines
9.2 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;
|
||
inferenceSteps?: number;
|
||
guidanceScale?: number;
|
||
maxImageSize?: number;
|
||
timeout?: number;
|
||
};
|
||
|
||
// 设置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 || 1);
|
||
|
||
// 3. 解析请求
|
||
const body = await request.json();
|
||
const {
|
||
image_url,
|
||
image_base64,
|
||
// 数值参数 (官方 API 格式)
|
||
horizontal_angle, // 0-360 度
|
||
vertical_angle, // -30 到 90 度
|
||
zoom, // 0-10
|
||
lora_scale,
|
||
} = body;
|
||
|
||
if (!image_url && !image_base64) {
|
||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||
}
|
||
|
||
// 使用请求中的参数或配置中的默认值
|
||
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
|
||
const finalHorizontalAngle = horizontal_angle ?? 0;
|
||
const finalVerticalAngle = vertical_angle ?? 0;
|
||
const finalZoom = zoom ?? 5;
|
||
|
||
console.log("[Angle-Edit] 角度参数:");
|
||
console.log(` - 水平角度: ${finalHorizontalAngle}°`);
|
||
console.log(` - 垂直角度: ${finalVerticalAngle}°`);
|
||
console.log(` - 缩放: ${finalZoom}`);
|
||
console.log(` - LoRA强度: ${finalLoraScale}`);
|
||
|
||
// 4. 准备图片输入
|
||
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}`;
|
||
}
|
||
|
||
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
|
||
console.log("[Angle-Edit] 调用 fal.ai API (qwen-image-edit-2511-multiple-angles)...");
|
||
|
||
const fal = await import("@fal-ai/serverless-client");
|
||
|
||
fal.config({
|
||
credentials: config.falApiKey,
|
||
});
|
||
|
||
// 使用官方多角度 API 端点
|
||
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
|
||
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
|
||
input: {
|
||
image_urls: [imageInput], // 官方使用 image_urls (数组)
|
||
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
|
||
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
|
||
zoom: finalZoom, // 缩放 0-10
|
||
lora_scale: finalLoraScale, // LoRA 强度
|
||
num_inference_steps: config.inferenceSteps || 28,
|
||
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
|
||
enable_safety_checker: true,
|
||
output_format: "png",
|
||
},
|
||
logs: true,
|
||
onQueueUpdate: (update) => {
|
||
if (update.status === "IN_PROGRESS") {
|
||
console.log("[Angle-Edit] 处理进度...");
|
||
}
|
||
},
|
||
});
|
||
|
||
// 6. 处理结果
|
||
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
|
||
const outputImage = apiResult.images?.[0];
|
||
if (!outputImage?.url) {
|
||
throw new Error("API 未返回有效图片");
|
||
}
|
||
|
||
const outputImageUrl = outputImage.url;
|
||
console.log("[Angle-Edit] 输出图片URL:", outputImageUrl);
|
||
console.log("[Angle-Edit] API 构建的提示词:", apiResult.prompt);
|
||
|
||
// 7. 下载图片并上传到我们的 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);
|
||
|
||
// 8. 保存到数据库
|
||
const processingTime = (Date.now() - startTime) / 1000;
|
||
|
||
await db.insert(generatedImage).values({
|
||
userId: session.user.id,
|
||
thumbnailUrl: s3Result.thumbnail,
|
||
originalUrl: s3Result.original,
|
||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||
createdAt: new Date(),
|
||
});
|
||
|
||
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
|
||
|
||
// 9. 返回结果
|
||
return NextResponse.json({
|
||
success: true,
|
||
image_url: s3Result.original,
|
||
thumbnail_url: s3Result.thumbnail,
|
||
image_base64: imageBase64Result,
|
||
processing_time: processingTime,
|
||
angles: {
|
||
horizontal_angle: finalHorizontalAngle,
|
||
vertical_angle: finalVerticalAngle,
|
||
zoom: finalZoom,
|
||
},
|
||
prompt: apiResult.prompt,
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("[Angle-Edit] ❌ 错误:", error);
|
||
|
||
// 输出详细的错误信息
|
||
if (error && typeof error === "object" && "status" in error) {
|
||
const apiError = error as { status?: number; body?: unknown; message?: string };
|
||
console.error("[Angle-Edit] API 错误详情:");
|
||
console.error(" - Status:", apiError.status);
|
||
console.error(" - Body:", JSON.stringify(apiError.body, null, 2));
|
||
console.error(" - Message:", apiError.message);
|
||
|
||
if (apiError.status === 403) {
|
||
return NextResponse.json(
|
||
{
|
||
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
|
||
details: apiError.body,
|
||
},
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{
|
||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|