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