386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import {
|
||
createMediaAssetRelation,
|
||
getMediaAssetsByUserAndUrls,
|
||
getPointsSystemConfig,
|
||
getSystemSetting,
|
||
upsertMediaAsset,
|
||
} 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";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||
|
||
const logger = createLogger("angle-edit-api");
|
||
|
||
// 角度编辑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();
|
||
|
||
logger.debug("thumbnail_created", {
|
||
originalBytes: buffer.length,
|
||
thumbnailBytes: thumbnailBuffer.length,
|
||
});
|
||
return thumbnailBuffer;
|
||
} catch (error) {
|
||
logger.warn("thumbnail_creation_failed", 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`;
|
||
|
||
logger.debug("source_image_prepared", { originalBytes: buffer.length });
|
||
|
||
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;
|
||
|
||
logger.debug("storage_upload_started", {
|
||
hasOriginal: originalArrayBuffer.byteLength > 0,
|
||
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
|
||
prefix,
|
||
});
|
||
const [thumbnailResult, originalResult] = await Promise.all([
|
||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||
uploadFile(originalFilename, originalArrayBuffer),
|
||
]);
|
||
|
||
logger.info("storage_upload_completed", {
|
||
originalUrl: originalResult.url,
|
||
thumbnailUrl: thumbnailResult.url,
|
||
});
|
||
|
||
return {
|
||
thumbnail: thumbnailResult.url,
|
||
original: originalResult.url,
|
||
};
|
||
} catch (error) {
|
||
logger.error("storage_upload_failed", 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 });
|
||
}
|
||
|
||
logger.info("request_received", { authenticated: true });
|
||
|
||
// 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 }
|
||
);
|
||
}
|
||
|
||
logger.debug("config_loaded", {
|
||
enabled: Boolean(config.enabled),
|
||
hasApiKey: Boolean(config.falApiKey),
|
||
loraScale: 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;
|
||
|
||
logger.info("request_parameters_normalized", {
|
||
hasBase64Input: Boolean(image_base64),
|
||
hasUrlInput: Boolean(image_url),
|
||
horizontalAngle: finalHorizontalAngle,
|
||
loraScale: finalLoraScale,
|
||
verticalAngle: finalVerticalAngle,
|
||
zoom: finalZoom,
|
||
});
|
||
|
||
const pointsConfig = await getPointsSystemConfig();
|
||
const pointsCharge = await prepareFeaturePointsCharge({
|
||
userId: session.user.id,
|
||
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
|
||
reason: "image_angle_edit",
|
||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||
note: "多角度编辑消耗积分",
|
||
pointsConfig,
|
||
});
|
||
if (!pointsCharge.ok) {
|
||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||
}
|
||
|
||
// 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
|
||
logger.info("provider_call_started", {
|
||
modelId: "fal-ai/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") {
|
||
logger.debug("provider_progress", { status: update.status });
|
||
}
|
||
},
|
||
});
|
||
|
||
// 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;
|
||
logger.info("provider_call_completed", {
|
||
hasGeneratedPrompt: Boolean(apiResult.prompt),
|
||
imageCount: apiResult.images?.length ?? 0,
|
||
outputImageUrl,
|
||
});
|
||
|
||
// 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");
|
||
logger.info("result_image_persisted", {
|
||
originalUrl: s3Result.original,
|
||
thumbnailUrl: s3Result.thumbnail,
|
||
});
|
||
|
||
// 8. 保存到数据库
|
||
const processingTime = (Date.now() - startTime) / 1000;
|
||
|
||
const [savedImage] = 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(),
|
||
})
|
||
.returning();
|
||
|
||
if (pointsCharge.chargeable) {
|
||
try {
|
||
await pointsCharge.consume({
|
||
note: `多角度编辑消耗积分(${savedImage.id})`,
|
||
});
|
||
} catch (pointsError) {
|
||
logger.error("points_consume_failed", pointsError, {
|
||
imageId: savedImage.id,
|
||
userId: session.user.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
try {
|
||
const sourceAssets =
|
||
typeof image_url === "string" && image_url.trim()
|
||
? await getMediaAssetsByUserAndUrls({
|
||
userId: session.user.id,
|
||
urls: [image_url],
|
||
})
|
||
: [];
|
||
const media = await upsertMediaAsset({
|
||
userId: session.user.id,
|
||
mediaType: "image",
|
||
sourceType: "edited",
|
||
status: "ready",
|
||
originalUrl: s3Result.original,
|
||
previewUrl: s3Result.original,
|
||
thumbnailUrl: s3Result.thumbnail,
|
||
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
|
||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||
sourceTable: "GeneratedImage",
|
||
sourceRowId: savedImage.id,
|
||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||
metadata: {
|
||
horizontalAngle: finalHorizontalAngle,
|
||
verticalAngle: finalVerticalAngle,
|
||
zoom: finalZoom,
|
||
},
|
||
});
|
||
if (media) {
|
||
for (const sourceAsset of sourceAssets) {
|
||
await createMediaAssetRelation({
|
||
userId: session.user.id,
|
||
fromAssetId: sourceAsset.id,
|
||
toAssetId: media.id,
|
||
relationType: "edited_from",
|
||
metadata: {
|
||
horizontalAngle: finalHorizontalAngle,
|
||
verticalAngle: finalVerticalAngle,
|
||
zoom: finalZoom,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
} catch (assetError) {
|
||
logger.warn("media_asset_registration_failed", assetError);
|
||
}
|
||
|
||
logger.info("request_completed", {
|
||
processingSeconds: Number(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) {
|
||
logger.error("request_failed", error);
|
||
|
||
// 输出详细的错误信息
|
||
if (error && typeof error === "object" && "status" in error) {
|
||
const apiError = error as { status?: number; body?: unknown; message?: string };
|
||
logger.warn("provider_error_summary", {
|
||
bodyType: typeof apiError.body,
|
||
hasBody: apiError.body != null,
|
||
message: apiError.message,
|
||
status: apiError.status,
|
||
});
|
||
|
||
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 }
|
||
);
|
||
}
|
||
}
|