pokerogue/scripts/optimize-audio.js

355 lines
12 KiB
JavaScript
Raw 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 { 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);
});