- 客户端压缩:保持原格式(JPG保持JPG,PNG保持PNG),不再强制转换为WebP - 提高压缩质量:最低质量从0.6提高到0.90,避免画质损失 - 启用高质量图像平滑:减少锯齿,提升画质 - 服务器端智能优化:使用MozJPEG和PNG优化算法 - 避免双重压缩:小文件(<3MB)跳过服务器端优化,只有优化效果明显(>5%)才使用 - 优化策略:优先缩小尺寸而不是降低质量,留余量给服务器端优化 技术改进: - JPEG: MozJPEG编码器,质量92%,渐进式,优化扫描 - PNG: 压缩级别6,自适应过滤,调色板优化 - 智能判断:避免不必要的双重压缩,保护画质
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|