import { exec } from 'child_process'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import os from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 获取CPU核心数并计算最佳并行数 const CPU_CORES = os.cpus().length; // 使用核心数-1作为并行数,确保系统仍有资源处理其他任务 // 同时设置最小值2和最大值8,避免太少或太多 const DEFAULT_CONCURRENT = Math.max(2, Math.min(CPU_CORES - 1, 8)); // 支持的音频格式 const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac']; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi']; // 获取文件扩展名(修改为大小写不敏感) const getExtension = (filePath) => { return path.extname(filePath).toLowerCase(); }; // 执行FFmpeg命令的Promise包装(减少日志输出) const execFFmpeg = (cmd) => { return new Promise((resolve, reject) => { // 添加线程优化参数,使用CPU核心数-1 const optimizedCmd = cmd + ` -threads ${Math.max(1, CPU_CORES - 1)}`; const process = exec(optimizedCmd, { stdio: ['pipe', 'pipe', 'pipe'] }, (error, stdout, stderr) => { if (error) { reject(error); return; } resolve(stdout); }); // 添加30秒超时 const timeout = setTimeout(() => { process.kill(); reject(new Error('FFmpeg执行超时')); }, 30000); process.on('exit', () => { clearTimeout(timeout); }); }); }; // 格式化文件大小 const formatFileSize = (bytes) => { if (bytes < 1024) { return `${bytes}B`; } else if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(2)}KB`; } else { return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; } }; // 比较文件大小并打印结果 const compareAndLogSize = (inputPath, originalSize, compressedSize) => { const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(2); const isLarger = compressedSize > originalSize; console.log(`${isLarger ? '压缩后文件更大,保留原文件' : '压缩完成'}: ${inputPath}`); console.log(`原始大小: ${formatFileSize(originalSize)}`); console.log(`压缩后大小: ${formatFileSize(compressedSize)}`); console.log(`${isLarger ? '体积增加' : '节省空间'}: ${isLarger ? -savings : savings}%`); return { isLarger, savings }; }; // 并行处理文件的函数 const processFilesInParallel = async (files, processFunction, maxConcurrent = DEFAULT_CONCURRENT) => { console.log(`使用 ${maxConcurrent} 个并行任务处理文件(系统共 ${CPU_CORES} 个CPU核心)`); const chunks = []; for (let i = 0; i < files.length; i += maxConcurrent) { chunks.push(files.slice(i, i + maxConcurrent)); } let processedCount = 0; let errorCount = 0; for (const chunk of chunks) { const results = await Promise.allSettled(chunk.map(file => processFunction(file))); results.forEach(result => { if (result.status === 'fulfilled') { processedCount++; } else { errorCount++; } }); } return { processedCount, errorCount }; }; // 音频优化 const optimizeAudio = async (inputPath) => { console.log(`开始处理音频文件: ${inputPath}`); // 统一使用小写扩展名 const extension = getExtension(inputPath); const tempOutputPath = inputPath.replace(/\.[^/.]+$/, `_temp${extension}`); const tempOpusPath = inputPath.replace(/\.[^/.]+$/, '_temp.opus'); let tempMp3Path = null; try { // 根据不同格式选择最佳压缩策略 if (extension === '.wav') { // WAV文件先转opus,再转MP3以获得更好的压缩效果 await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${inputPath}" -c:a libopus -b:a 48k -ac 1 -ar 24000 -application audio "${tempOpusPath}"`); // 转回MP3格式,使用VBR模式 tempMp3Path = inputPath.replace(/\.[^/.]+$/, '_temp.mp3'); await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${tempOpusPath}" -c:a libmp3lame -q:a 5 -ac 1 -ar 22050 -compression_level 0 "${tempMp3Path}"`); // 比较文件大小 const originalSize = fs.statSync(inputPath).size; const mp3Size = fs.statSync(tempMp3Path).size; const { isLarger } = compareAndLogSize(inputPath, originalSize, mp3Size); if (!isLarger) { // 如果MP3更小,替换原文件 fs.unlinkSync(inputPath); fs.renameSync(tempMp3Path, inputPath); } return inputPath; } else if (extension === '.m4a') { // m4a文件使用opus中转以获得更好的压缩效果 await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${inputPath}" -c:a libopus -b:a 48k -ac 1 -ar 24000 -application audio "${tempOpusPath}"`); // 转回m4a/aac格式,使用更优化的参数 await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${tempOpusPath}" -c:a aac -b:a 48k -ac 1 -ar 22050 -profile:a aac_low -movflags +faststart -compression_level 0 "${tempOutputPath}"`); // 清理临时opus文件 if (fs.existsSync(tempOpusPath)) { fs.unlinkSync(tempOpusPath); } } else { // 其他格式使用opus中转 await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${inputPath}" -c:a libopus -b:a 48k -ac 1 -ar 24000 -application audio "${tempOpusPath}"`); // 第二步:转回原格式 let codecCmd; switch(extension) { case '.mp3': // 使用LAME的VBR模式,质量等级5(范围0-9,数字越大文件越小) codecCmd = '-c:a libmp3lame -q:a 5 -ac 1 -ar 22050'; break; case '.ogg': // 使用Vorbis编码器的VBR模式,质量等级更激进 codecCmd = '-c:a libvorbis -q:a 2 -ac 1 -ar 22050'; break; case '.flac': // 使用最大压缩级别,并降低采样率 codecCmd = '-c:a flac -compression_level 12 -ac 1 -ar 22050'; break; case '.aac': // 使用AAC低复杂度配置,更低的比特率 codecCmd = '-c:a aac -b:a 48k -ac 1 -ar 22050 -profile:a aac_low'; break; default: codecCmd = '-c:a libmp3lame -q:a 5 -ac 1 -ar 22050'; } // 添加faststart标志以优化文件结构(对于支持的格式) if (['.m4a', '.aac', '.mp4'].includes(extension)) { codecCmd += ' -movflags +faststart'; } await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${tempOpusPath}" ${codecCmd} "${tempOutputPath}"`); } // 比较文件大小 const originalSize = fs.statSync(inputPath).size; const compressedSize = fs.statSync(tempOutputPath).size; const { isLarger } = compareAndLogSize(inputPath, originalSize, compressedSize); if (isLarger) { // 如果压缩后文件更大,保留原文件 fs.unlinkSync(tempOutputPath); return inputPath; } // 替换原文件 fs.unlinkSync(inputPath); fs.renameSync(tempOutputPath, inputPath); return inputPath; } catch (error) { console.error(`处理文件失败: ${inputPath}`); throw error; } finally { // 清理所有临时文件 const tempFiles = [tempOutputPath, tempOpusPath, tempMp3Path].filter(Boolean); for (const tempFile of tempFiles) { try { if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); console.log(`已清理临时文件: ${tempFile}`); } } catch (e) { console.error(`清理临时文件失败: ${tempFile}`, e); } } } }; // 视频优化 const optimizeVideo = async (inputPath) => { console.log(`开始处理视频文件: ${inputPath}`); const extension = getExtension(inputPath); const tempOutputPath = inputPath.replace(extension, `_temp${extension}`); try { // 根据不同格式选择合适的编码器和参数 let codecCmd; switch(extension) { case '.webm': // VP9编码器,更激进的压缩参数 codecCmd = `-c:v libvpx-vp9 -crf 35 -b:v 0 -deadline good -cpu-used 4 -row-mt 1 -tile-columns 2 -tile-rows 2 ` + `-vf "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease" ` + `-c:a libopus -b:a 48k -ac 1 -ar 24000 -application audio`; break; case '.mov': case '.avi': // 转换为MP4格式,使用x264编码器 tempOutputPath = inputPath.replace(extension, '_temp.mp4'); codecCmd = `-c:v libx264 -crf 28 -preset fast -tune fastdecode -profile:v high ` + `-vf "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease" ` + `-c:a aac -b:a 48k -ac 1 -ar 24000 -profile:a aac_low -movflags +faststart`; break; default: // mp4 // 使用x264编码器,更激进的压缩参数 codecCmd = `-c:v libx264 -crf 28 -preset fast -tune fastdecode -profile:v high ` + `-vf "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease" ` + `-c:a aac -b:a 48k -ac 1 -ar 24000 -profile:a aac_low -movflags +faststart`; } // 添加通用的视频优化参数 codecCmd += ` -sws_flags lanczos+accurate_rnd -map_metadata -1`; await execFFmpeg(`ffmpeg -hide_banner -loglevel error -i "${inputPath}" ${codecCmd} "${tempOutputPath}"`); // 比较文件大小 const originalSize = fs.statSync(inputPath).size; const compressedSize = fs.statSync(tempOutputPath).size; const { isLarger } = compareAndLogSize(inputPath, originalSize, compressedSize); if (isLarger) { // 如果压缩后文件更大,保留原文件 fs.unlinkSync(tempOutputPath); return inputPath; } // 替换原文件(如果是mov/avi转mp4,则删除原文件) fs.unlinkSync(inputPath); fs.renameSync(tempOutputPath, inputPath.replace(extension, extension === '.mov' || extension === '.avi' ? '.mp4' : extension)); return inputPath; } catch (error) { if (fs.existsSync(tempOutputPath)) { fs.unlinkSync(tempOutputPath); } throw error; } }; // 递归处理目录 const processDirectory = async (dir) => { console.log(`开始扫描目录: ${dir}`); if (!fs.existsSync(dir)) { console.error(`错误: 目录不存在 - ${dir}`); return { processedCount: 0, errorCount: 0 }; } const files = fs.readdirSync(dir); console.log(`在 ${dir} 中找到 ${files.length} 个文件/目录`); const tasks = []; const subdirs = []; for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { subdirs.push(filePath); } else { const extension = getExtension(filePath); if (AUDIO_EXTENSIONS.includes(extension)) { tasks.push({ path: filePath, type: 'audio' }); } else if (VIDEO_EXTENSIONS.includes(extension)) { tasks.push({ path: filePath, type: 'video' }); } } } // 并行处理文件 let results = { processedCount: 0, errorCount: 0 }; if (tasks.length > 0) { const processFile = async (task) => { try { if (task.type === 'audio') { await optimizeAudio(task.path); } else { await optimizeVideo(task.path); } } catch (error) { console.error(`处理文件 ${task.path} 时出错:`, error); throw error; } }; const parallelResults = await processFilesInParallel(tasks, processFile); results.processedCount += parallelResults.processedCount; results.errorCount += parallelResults.errorCount; } // 递归处理子目录 for (const subdir of subdirs) { const subdirResults = await processDirectory(subdir); results.processedCount += subdirResults.processedCount; results.errorCount += subdirResults.errorCount; } return results; }; // 主目录 const mainDir = path.join(__dirname, '../public'); console.log('开始音视频优化处理...'); console.log(`主目录: ${mainDir}`); console.log('支持的音频格式:', AUDIO_EXTENSIONS.join(', ')); console.log('支持的视频格式:', VIDEO_EXTENSIONS.join(', ')); console.log('警告:此操作将直接替换原文件,请确保已备份重要文件'); // 处理主目录(添加错误处理) processDirectory(mainDir) .then((results) => { console.log('\n处理总结:'); console.log(`- 成功处理文件数: ${results.processedCount}`); console.log(`- 处理失败文件数: ${results.errorCount}`); console.log('所有文件处理完成!'); }) .catch(error => { console.error('处理过程中出错:', error); process.exit(1); });