Your Name b12dd2d77a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: Integrate Qwen-Image-Edit 3D angle controller and update UI
2026-01-10 23:07:14 +08:00

300 lines
9.9 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.

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 }
);
}
}