Your Name db070f8a37
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: increase request body size limit to 500MB for video uploads
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-31 21:32:07 +08:00

286 lines
8.8 KiB
TypeScript
Raw Permalink 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 { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { exec } from "child_process";
import { promisify } from "util";
import { writeFile, unlink, readFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
// 最大文件大小:500MB
const MAX_FILE_SIZE = 500 * 1024 * 1024;
// 支持的视频格式
const SUPPORTED_FORMATS = [
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/x-matroska",
"video/webm",
"video/mpeg",
"video/x-m4v",
];
// 检查 FFmpeg 是否可用
async function checkFFmpeg(): Promise<boolean> {
try {
await execAsync("ffmpeg -version");
return true;
} catch {
return false;
}
}
// 获取视频信息
async function getVideoInfo(
inputPath: string
): Promise<{ duration: number; width: number; height: number; bitrate: number }> {
try {
const { stdout } = await execAsync(
`ffprobe -v quiet -print_format json -show_format -show_streams "${inputPath}"`
);
const info = JSON.parse(stdout);
const videoStream = info.streams?.find((s: any) => s.codec_type === "video");
return {
duration: parseFloat(info.format?.duration || "0"),
width: videoStream?.width || 0,
height: videoStream?.height || 0,
bitrate: parseInt(info.format?.bit_rate || "0"),
};
} catch (error) {
console.error("[Video Upload] Failed to get video info:", error);
return { duration: 0, width: 0, height: 0, bitrate: 0 };
}
}
// 使用 FFmpeg 压缩视频
async function compressVideo(
inputPath: string,
outputPath: string,
options: {
targetWidth?: number;
crf?: number; // 质量因子,18-28 推荐,越低质量越高
preset?: string; // 编码速度预设
audioBitrate?: string;
} = {}
): Promise<{ success: boolean; originalSize: number; compressedSize: number }> {
const {
targetWidth = 1280, // 默认 720p
crf = 23, // 默认质量
preset = "medium", // 编码速度
audioBitrate = "128k",
} = options;
const videoInfo = await getVideoInfo(inputPath);
const originalSize = (await readFile(inputPath)).length;
// 构建 FFmpeg 命令 - 针对 Web 流式播放深度优化
// 使用 H.264 (libx264) 编码器 + AAC 音频
//
// 关键优化参数说明:
// -movflags +faststart: 将 moov atom 移到文件开头,支持边下载边播放
// -profile:v high: 使用 High Profile,更好的压缩率和兼容性
// -level 4.1: 兼容大多数设备的 Level
// -pix_fmt yuv420p: 确保最广泛的浏览器兼容性
// -tune film/animation: 根据内容优化(这里用 film 作为通用选择)
// -g 48: 关键帧间隔,约每2秒一个关键帧,优化 seek 体验
// -keyint_min 24: 最小关键帧间隔
// -sc_threshold 0: 禁用场景切换检测,保持稳定的关键帧间隔
// -bf 2: B帧数量,提高压缩率
// -b_strategy 2: B帧策略,动态选择
let scaleFilter = "";
if (videoInfo.width > targetWidth) {
// 使用 lanczos 缩放算法,质量最好
scaleFilter = `-vf "scale=${targetWidth}:-2:flags=lanczos"`;
}
const ffmpegCmd = `ffmpeg -i "${inputPath}" \
-c:v libx264 \
-preset ${preset} \
-crf ${crf} \
-profile:v high \
-level 4.1 \
-pix_fmt yuv420p \
-g 48 \
-keyint_min 24 \
-sc_threshold 0 \
-bf 2 \
-b_strategy 2 \
${scaleFilter} \
-c:a aac \
-b:a ${audioBitrate} \
-ar 44100 \
-ac 2 \
-movflags +faststart \
-y \
"${outputPath}"`;
console.log("[Video Compress] Running FFmpeg:", ffmpegCmd);
try {
await execAsync(ffmpegCmd, { maxBuffer: 50 * 1024 * 1024 });
const compressedSize = (await readFile(outputPath)).length;
console.log(
`[Video Compress] Original: ${(originalSize / 1024 / 1024).toFixed(2)}MB, ` +
`Compressed: ${(compressedSize / 1024 / 1024).toFixed(2)}MB, ` +
`Ratio: ${((1 - compressedSize / originalSize) * 100).toFixed(1)}%`
);
return { success: true, originalSize, compressedSize };
} catch (error) {
console.error("[Video Compress] FFmpeg error:", error);
return { success: false, originalSize, compressedSize: 0 };
}
}
export async function POST(request: NextRequest) {
try {
// 验证用户身份
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
// 只允许管理员上传教程视频
if (session.user.type !== "admin") {
return NextResponse.json({ error: "仅管理员可上传视频" }, { status: 403 });
}
// 检查 FFmpeg
const hasFFmpeg = await checkFFmpeg();
console.log("[Video Upload] FFmpeg available:", hasFFmpeg);
// 解析上传的文件
const formData = await request.formData();
const file = formData.get("file") as File | null;
const compress = formData.get("compress") !== "false"; // 默认压缩
const quality = formData.get("quality") as string || "medium"; // low, medium, high
if (!file) {
return NextResponse.json({ error: "未找到视频文件" }, { status: 400 });
}
// 验证文件类型
if (!SUPPORTED_FORMATS.includes(file.type)) {
return NextResponse.json(
{ error: `不支持的视频格式: ${file.type}` },
{ status: 400 }
);
}
// 验证文件大小
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: `文件过大,最大支持 ${MAX_FILE_SIZE / 1024 / 1024}MB` },
{ status: 400 }
);
}
console.log(
`[Video Upload] Received file: ${file.name}, size: ${(file.size / 1024 / 1024).toFixed(2)}MB, type: ${file.type}`
);
// 创建临时目录
const tempDir = path.join(os.tmpdir(), "video-upload");
if (!existsSync(tempDir)) {
await mkdir(tempDir, { recursive: true });
}
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const inputPath = path.join(tempDir, `input-${timestamp}-${randomStr}.mp4`);
const outputPath = path.join(tempDir, `output-${timestamp}-${randomStr}.mp4`);
// 写入临时文件
const bytes = await file.arrayBuffer();
await writeFile(inputPath, Buffer.from(bytes));
let finalBuffer: Buffer;
let compressionInfo = {
compressed: false,
originalSize: file.size,
compressedSize: file.size,
ratio: 0,
};
// 如果有 FFmpeg 且需要压缩
if (hasFFmpeg && compress) {
// 根据质量选择 CRF 值
const crfMap: Record<string, number> = {
low: 28, // 较低质量,文件更小
medium: 23, // 中等质量(推荐)
high: 18, // 高质量,文件较大
};
const crf = crfMap[quality] || 23;
const result = await compressVideo(inputPath, outputPath, {
crf,
preset: quality === "low" ? "fast" : quality === "high" ? "slow" : "medium",
targetWidth: quality === "low" ? 720 : quality === "high" ? 1920 : 1280,
});
if (result.success) {
finalBuffer = await readFile(outputPath);
compressionInfo = {
compressed: true,
originalSize: result.originalSize,
compressedSize: result.compressedSize,
ratio: Math.round((1 - result.compressedSize / result.originalSize) * 100),
};
} else {
// 压缩失败,使用原文件
finalBuffer = Buffer.from(bytes);
}
} else {
// 不压缩,直接使用原文件
finalBuffer = Buffer.from(bytes);
}
// 上传到存储
const filename = `tutorial-${timestamp}-${randomStr}.mp4`;
// 将 Buffer 转换为 ArrayBuffer
const uint8Array = new Uint8Array(finalBuffer);
const arrayBuffer = uint8Array.buffer.slice(
uint8Array.byteOffset,
uint8Array.byteOffset + uint8Array.byteLength
) as ArrayBuffer;
const uploadResult = await uploadFile(filename, arrayBuffer);
console.log(`[Video Upload] Uploaded to: ${uploadResult.url}`);
// 清理临时文件
try {
await unlink(inputPath);
if (existsSync(outputPath)) {
await unlink(outputPath);
}
} catch {
// 忽略清理错误
}
return NextResponse.json({
success: true,
url: uploadResult.url,
filename: filename,
compression: compressionInfo,
});
} catch (error) {
console.error("[Video Upload] Error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "上传失败" },
{ status: 500 }
);
}
}
// App Router 配置:增加请求体大小限制
// 参考: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
export const maxDuration = 300; // 5分钟超时
export const dynamic = "force-dynamic";