286 lines
8.8 KiB
TypeScript
286 lines
8.8 KiB
TypeScript
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";
|