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 { 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 = { 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";