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 命令 // 使用 H.264 (libx264) 编码器 + AAC 音频 // -movflags +faststart 让视频可以边下载边播放 let scaleFilter = ""; if (videoInfo.width > targetWidth) { scaleFilter = `-vf "scale=${targetWidth}:-2"`; } const ffmpegCmd = `ffmpeg -i "${inputPath}" \ -c:v libx264 \ -preset ${preset} \ -crf ${crf} \ ${scaleFilter} \ -c:a aac \ -b:a ${audioBitrate} \ -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 } ); } } // 配置较大的请求体限制 export const config = { api: { bodyParser: false, }, };