Your Name 12583cdd10
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: sync latest canvas and media updates
2026-03-06 17:31:32 +08:00

217 lines
8.3 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 { upsertMediaAsset } from "@/lib/db/queries";
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 <= 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"].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);
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) {
console.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
}
console.log("[Upload API] Upload successful:", data.url);
return NextResponse.json({
...data,
assetId,
});
} 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 }
);
}
}