Your Name fea5e0fac5 优化图片压缩技术:避免双重压缩,保持JPG/PNG格式
- 客户端压缩:保持原格式(JPG保持JPG,PNG保持PNG),不再强制转换为WebP
- 提高压缩质量:最低质量从0.6提高到0.90,避免画质损失
- 启用高质量图像平滑:减少锯齿,提升画质
- 服务器端智能优化:使用MozJPEG和PNG优化算法
- 避免双重压缩:小文件(<3MB)跳过服务器端优化,只有优化效果明显(>5%)才使用
- 优化策略:优先缩小尺寸而不是降低质量,留余量给服务器端优化

技术改进:
- JPEG: MozJPEG编码器,质量92%,渐进式,优化扫描
- PNG: 压缩级别6,自适应过滤,调色板优化
- 智能判断:避免不必要的双重压缩,保护画质
2026-01-02 20:46:01 +08:00

190 lines
7.5 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 { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
* - 如果文件已经很小(<3MB),跳过优化(客户端已处理)
* - 如果文件较大,使用MozJPEG/PNG优化算法进一步优化
*/
async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const fileSizeMB = buffer.length / 1024 / 1024;
const OPTIMIZE_THRESHOLD_MB = 3; // 3MB阈值:小于此值不优化(避免双重压缩)
// ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩)
if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) {
console.log(`[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%才使用
console.log(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
return optimized;
} else {
console.log(`[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%才使用
console.log(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
return optimized;
} else {
console.log(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
return buffer;
}
}
// 其他格式返回原图
return buffer;
} catch (error) {
console.error("[Upload] 图片优化失败,使用原图:", error);
// 优化失败时返回原图,不影响上传流程
return 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 <= 8 * 1024 * 1024, {
message: "File size should be less than 8MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), {
message: "File type should be JPEG, PNG or WebP",
}),
});
export async function POST(request: Request) {
console.log("[Upload API] Received upload request");
const session = await auth();
if (!session) {
console.log("[Upload API] Unauthorized - no session");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
console.log("[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;
console.log("[Upload API] FormData received, file exists:", !!file);
if (!file) {
console.log("[Upload API] No file in FormData");
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
console.log("[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(", ");
console.log("[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;
console.log("[Upload API] Filename:", filename);
const fileBuffer = await file.arrayBuffer();
console.log("[Upload API] File buffer size:", fileBuffer.byteLength);
try {
// ✅ 使用Sharp优化图片(保持原格式)
let optimizedBuffer: Buffer = Buffer.from(fileBuffer);
// ✅ 优化原图(保持JPG/PNG格式)
// WebP格式不优化(用于预览图,保持原样)
if (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png') {
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
} else if (file.type === 'image/webp') {
// WebP格式不优化(用于预览图),直接使用
console.log(`[Upload] WebP格式(预览图),跳过优化,直接上传`);
}
// 将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);
console.log("[Upload API] Upload successful:", data.url);
return NextResponse.json(data);
} catch (_error) {
console.error("[Upload API] Upload failed:", _error);
return NextResponse.json({
error: "Upload failed",
details: _error instanceof Error ? _error.message : String(_error)
}, { status: 500 });
}
} catch (_error) {
console.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 }
);
}
}