import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images"; import { upsertMediaAsset } from "@/lib/db/queries"; import { isSiteAdminType } from "@/lib/middleware/admin"; import { uploadFile } from "@/lib/storage/service"; import { createLogger } from "@/lib/observability/logger"; const logger = createLogger("upload-api"); /** * 使用Sharp智能优化图片(保持原格式) * 策略:避免双重压缩,只在需要时优化 * - 如果文件已经很小(<3MB),跳过优化(客户端已处理) * - 如果文件较大,使用MozJPEG/PNG优化算法进一步优化 */ async function optimizeImage(buffer: Buffer, mimeType: string): Promise { try { const fileSizeMB = buffer.length / 1024 / 1024; const OPTIMIZE_THRESHOLD_MB = 3; // 3MB阈值:小于此值不优化(避免双重压缩) // ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩) if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) { logger.debug(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`); return buffer; } const sharp = await import("sharp"); const image = sharp.default(buffer); if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') { // ✅ 使用MozJPEG编码器,高质量压缩 // 注意:这里使用MozJPEG重新编码,使用更好的算法,而不是降低质量 const optimized = await image .jpeg({ quality: 92, // 高质量(92%质量,画质优秀) mozjpeg: true, // 使用MozJPEG编码器(比标准JPEG压缩率高15-25%,但质量更好) progressive: true, // 渐进式JPEG,加载体验更好 optimizeScans: true, // 优化扫描 trellisQuantisation: true, // 网格量化,更好的压缩 overshootDeringing: true, // 过冲去振铃,减少伪影 optimizeCoding: true, // 优化编码 }) .toBuffer(); // 只有优化后文件明显更小才使用(避免不必要的处理) const reductionPercent = ((1 - optimized.length / buffer.length) * 100); if (reductionPercent > 5) { // 只有减少超过5%才使用 logger.debug(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); return optimized; } else { logger.debug(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); return buffer; } } else if (mimeType === 'image/png') { // ✅ PNG优化压缩(无损优化) const metadata = await image.metadata(); const optimized = await image .png({ compressionLevel: 6, // 压缩级别(0-9,6是平衡点) adaptiveFiltering: true, // 自适应过滤,减少文件大小 palette: !metadata.hasAlpha, // 无透明通道时使用调色板模式 quality: 100, // PNG质量(100=无损) effort: 6, // 压缩努力程度(0-10,6是平衡点) }) .toBuffer(); // 只有优化后文件明显更小才使用 const reductionPercent = ((1 - optimized.length / buffer.length) * 100); if (reductionPercent > 5) { // 只有减少超过5%才使用 logger.debug(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); return optimized; } else { logger.debug(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); return buffer; } } // 其他格式返回原图 return buffer; } catch (error) { logger.error("[Upload] 图片优化失败,使用原图:", error); // 优化失败时返回原图,不影响上传流程 return buffer; } } function toArrayBuffer(buffer: Buffer): ArrayBuffer { const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); return view.slice().buffer; } // Use Blob instead of File since File is not available in Node.js environment const FileSchema = z.object({ file: z .instanceof(Blob) .refine((file) => file.size <= 50 * 1024 * 1024, { message: "File size should be less than 50MB", }) // Update the file type based on the kind of files you want to accept .refine( (file) => ["image/jpeg", "image/png", "image/webp", "image/svg+xml"].includes( file.type ), { message: "File type should be JPEG, PNG, WebP or SVG", } ), }); export async function POST(request: Request) { logger.debug("[Upload API] Received upload request"); const session = await auth(); if (!session) { logger.debug("[Upload API] Unauthorized - no session"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (request.body === null) { logger.debug("[Upload API] Request body is empty"); return new Response("Request body is empty", { status: 400 }); } try { const formData = await request.formData(); const file = formData.get("file") as Blob; logger.debug("[Upload API] FormData received, file exists:", !!file); if (!file) { logger.debug("[Upload API] No file in FormData"); return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } const uploadPurpose = String(formData.get("purpose") || ""); const isSvgUpload = file.type === "image/svg+xml"; if ( isSvgUpload && (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) ) { return NextResponse.json( { error: "SVG uploads are only allowed for administrator icon settings" }, { status: 403 } ); } logger.debug("[Upload API] File info:", { type: file.type, size: file.size, sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB" }); const validatedFile = FileSchema.safeParse({ file }); if (!validatedFile.success) { const errorMessage = validatedFile.error.errors .map((error) => error.message) .join(", "); logger.debug("[Upload API] Validation failed:", errorMessage); return NextResponse.json({ error: errorMessage }, { status: 400 }); } // Get filename from formData since Blob doesn't have name property const filename = (formData.get("file") as File).name; logger.debug("[Upload API] Filename:", filename); const fileBuffer = await file.arrayBuffer(); logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength); try { if (uploadPurpose === "agent-workflow-preview" && file.type !== "image/svg+xml") { const preview = await createAgentWorkflowPreviewImageBuffer(Buffer.from(fileBuffer)); const previewResult = await uploadFile( `${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`, toArrayBuffer(preview.buffer) ); let assetId: string | null = null; try { const asset = await upsertMediaAsset({ userId: session.user.id, mediaType: "image", sourceType: "agent_workflow_preview_upload", status: "ready", originalUrl: previewResult.url, previewUrl: previewResult.url, thumbnailUrl: previewResult.url, mimeType: "image/webp", width: preview.width, height: preview.height, displayName: filename, sourceTable: "agent_workflow_preview_upload", metadata: { pathname: previewResult.pathname, originalSize: fileBuffer.byteLength, previewSize: preview.buffer.length, purpose: uploadPurpose, }, }); assetId = asset?.id ?? null; } catch (assetError) { logger.warn("[Upload API] Failed to register workflow preview asset:", assetError); } return NextResponse.json({ ...previewResult, url: previewResult.url, previewUrl: previewResult.url, thumbnailUrl: previewResult.url, originalUrl: previewResult.url, previewImageSourceUrl: previewResult.url, assetId, width: preview.width, height: preview.height, optimizedFor: uploadPurpose, }); } // ✅ 使用Sharp优化图片(保持原格式) let optimizedBuffer: Buffer = Buffer.from(fileBuffer); // ✅ 优化原图(保持JPG/PNG格式) // WebP格式不优化(用于预览图,保持原样) const shouldOptimizeImage = uploadPurpose !== "canvas-upload" && (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png'); if (shouldOptimizeImage) { optimizedBuffer = await optimizeImage(optimizedBuffer, file.type); } else if (file.type === 'image/webp' || file.type === 'image/svg+xml') { // WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。 logger.debug(`[Upload] ${file.type}格式,跳过优化,直接上传`); } else if (uploadPurpose === "canvas-upload") { logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop"); } // 将Buffer转换为ArrayBuffer用于上传 const optimizedArrayBuffer = optimizedBuffer.buffer.slice( optimizedBuffer.byteOffset, optimizedBuffer.byteOffset + optimizedBuffer.byteLength ) as ArrayBuffer; // 使用统一的存储服务(支持数据库配置的 S3、环境变量 S3 或 Vercel Blob) // 使用优化后的buffer上传 const data = await uploadFile(filename, optimizedArrayBuffer); let assetId: string | null = null; try { const asset = await upsertMediaAsset({ userId: session.user.id, mediaType: "image", sourceType: "upload", status: "ready", originalUrl: data.url, previewUrl: data.url, thumbnailUrl: data.url, mimeType: data.contentType || file.type || null, displayName: filename, sourceTable: "upload", metadata: { pathname: data.pathname, size: optimizedBuffer.length, }, }); assetId = asset?.id ?? null; } catch (assetError) { logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError); } logger.debug("[Upload API] Upload successful:", data.url); return NextResponse.json({ ...data, assetId, }); } catch (_error) { logger.error("[Upload API] Upload failed:", _error); return NextResponse.json({ error: "Upload failed", details: _error instanceof Error ? _error.message : String(_error) }, { status: 500 }); } } catch (_error) { logger.error("[Upload API] Request processing failed:", _error); return NextResponse.json( { error: "Failed to process request", details: _error instanceof Error ? _error.message : String(_error) }, { status: 500 } ); } }