|
const fse = require('fs-extra'); |
|
const path = require('path'); |
|
const { exec, spawn } = require('child_process'); |
|
const readline = require('readline'); |
|
const edge = require('edge-js'); |
|
|
|
const sourceDirectory = path.resolve('/path/to/source'); // 源文件目录 |
|
const presetFile = path.resolve(__dirname, 'conf.json'); // HandBrake导出的预设文件 |
|
const logErr = path.resolve(__dirname, 'log/error.txt'); // 日志文件路径 |
|
|
|
const videoExtensions = [ |
|
'.avi', '.mp4', '.mkv_no', '.mov', '.wmv', '.flv', '.webm_no', |
|
'.mpeg', '.mpg', '.3gp', '.m4v', '.rm', '.rmvb', '.vob', |
|
'.ts', '.mts', '.m2ts', '.divx', '.xvid', '.asf', '.qt', |
|
'.f4v', '.ogv', '.swf' |
|
] |
|
|
|
// 运行主函数 |
|
convertVideoFiles(sourceDirectory).catch(error => { |
|
console.error(`Failed to convert video files: ${error}`); |
|
}); |
|
|
|
// 主函数 |
|
async function convertVideoFiles(directory) { |
|
const files = await getFilesRecursively(directory); |
|
const categorizedFiles = categorizeFilesByDirectory(files); |
|
|
|
for (const [dir, filesInDir] of categorizedFiles) { |
|
await processDirectory(dir, filesInDir); |
|
} |
|
} |
|
|
|
// 处理每个目录的文件 |
|
async function processDirectory(directory, files) { |
|
const videoFiles = files.filter(isVideoFile); |
|
|
|
if (videoFiles.length > 2 && videoFiles.every(isMp4File)) { |
|
const isFirstFileProcessed = await isCompressed(videoFiles[0]); |
|
const isLastFileProcessed = await isCompressed(videoFiles[videoFiles.length - 1]); |
|
|
|
if (isFirstFileProcessed && isLastFileProcessed) { |
|
console.log(`Skipping directory ${directory}. Already processed.`); |
|
return; |
|
} |
|
} |
|
|
|
for (const file of videoFiles) { |
|
await processVideoFile(file); |
|
} |
|
} |
|
|
|
// 处理单个视频文件 |
|
async function processVideoFile(file) { |
|
const extension = path.extname(file).toLowerCase(); |
|
const outputFileNameBase = `${path.basename(file, path.extname(file))}`; |
|
const outputFileName = `${outputFileNameBase}.processing`; |
|
const outputPath = path.join(path.dirname(file), outputFileName); |
|
const compressedFile = path.join(path.dirname(file), `${outputFileNameBase}.mp4`); |
|
const originSize = fse.statSync(file).size; |
|
|
|
try { |
|
if (extension === '.mp4' && await isCompressed(file)) { |
|
console.log(`Skipping file: ${file}`); |
|
return; |
|
} |
|
|
|
const streamInfo = await getStreamInfo(file); |
|
console.log(`Processing file: ${file}\n${streamInfo}`); |
|
|
|
await executeHandbrake(file, outputPath); |
|
|
|
const compressedSize = fse.statSync(outputPath).size; |
|
const offRatio = calculateOffRatio(originSize, compressedSize); |
|
|
|
process.stdout.write(` Size: ${bytesToSize(originSize)} => ${bytesToSize(compressedSize)} (${offRatio})`); |
|
|
|
if (compressedSize >= originSize) { |
|
// 压缩后文件大于原始文件,放弃压缩 |
|
process.stdout.write(` Droped!\n\n`); |
|
await fse.unlink(outputPath); // 删除临时文件 |
|
await setCompressed(file); // 设置压缩标记 |
|
|
|
if (extension === '.mp4') { |
|
await fse.rename(file, compressedFile); // 重命名原始文件 |
|
} |
|
} else { |
|
process.stdout.write('\n\n'); |
|
await fse.unlink(file); // 删除原始文件 |
|
await fse.rename(outputPath, compressedFile); // 重命名压缩文件 |
|
await setCompressed(compressedFile); // 设置压缩标记 |
|
} |
|
} catch (error) { |
|
logError(`Failed to process file: ${file}\n${error.message}`); |
|
} |
|
} |
|
|
|
// 获取目录中的所有文件(递归) |
|
async function getFilesRecursively(directory) { |
|
const files = []; |
|
const fileNames = await fse.readdir(directory); |
|
|
|
for (const fileName of fileNames) { |
|
const filePath = path.join(directory, fileName); |
|
const stats = await fse.stat(filePath); |
|
|
|
if (stats.isDirectory()) { |
|
const subFiles = await getFilesRecursively(filePath); |
|
files.push(...subFiles); |
|
} else { |
|
files.push(filePath); |
|
} |
|
} |
|
|
|
return files; |
|
} |
|
|
|
// 按目录分类文件 |
|
function categorizeFilesByDirectory(files) { |
|
const categorizedFiles = new Map(); |
|
|
|
for (const file of files) { |
|
const directory = path.dirname(file); |
|
|
|
if (!categorizedFiles.has(directory)) { |
|
categorizedFiles.set(directory, []); |
|
} |
|
|
|
categorizedFiles.get(directory).push(file); |
|
} |
|
|
|
return categorizedFiles; |
|
} |
|
|
|
// 判断是否为视频文件 |
|
function isVideoFile(file) { |
|
const extension = path.extname(file).toLowerCase(); |
|
return videoExtensions.includes(extension); |
|
} |
|
|
|
// 判断是否为 MP4 文件 |
|
function isMp4File(file) { |
|
const extension = path.extname(file).toLowerCase(); |
|
return extension === '.mp4'; |
|
} |
|
|
|
// 判断是否为已压缩文件 |
|
async function isCompressed(path) { |
|
const func = edge.func(` |
|
#r "TagLibSharp.dll" |
|
|
|
using System; |
|
using System.Threading.Tasks; |
|
using TagLib; |
|
|
|
async (filePath) => { |
|
using (var file = TagLib.File.Create(filePath.ToString())) |
|
{ |
|
if (file.Tag.Comment == "compressed") { |
|
return true; |
|
} else if (!String.IsNullOrEmpty(file.Tag.Comment)) { |
|
Console.WriteLine("Comment: " + file.Tag.Comment); |
|
} |
|
return false; |
|
} |
|
} |
|
`); |
|
return new Promise((resolve, reject) => { |
|
func(path, (error, result) => { |
|
if (error) { |
|
reject(error); |
|
} else { |
|
resolve(result); |
|
} |
|
}) |
|
}) |
|
} |
|
|
|
// 设置文件为已压缩状态 |
|
async function setCompressed(file) { |
|
const func = edge.func(` |
|
#r "TagLibSharp.dll" |
|
|
|
using System; |
|
using System.Threading.Tasks; |
|
using TagLib; |
|
|
|
async (filePath) => { |
|
using (var file = TagLib.File.Create(filePath.ToString())) |
|
{ |
|
file.Tag.Comment = "compressed"; |
|
|
|
file.Save(); |
|
} |
|
|
|
return true; |
|
} |
|
`); |
|
|
|
return new Promise((resolve, reject) => { |
|
func(file, (error, result) => { |
|
if (error) { |
|
reject(error); |
|
} else { |
|
resolve(result); |
|
} |
|
}) |
|
}) |
|
} |
|
|
|
// 获取视频流信息 |
|
async function getStreamInfo(file) { |
|
const FFPROBE_PATH = path.join(__dirname, 'ffprobe.exe'); |
|
const FFPROBE_ARGS = ['-i', file, '-hide_banner']; |
|
|
|
const createPromise = new Promise((resolve, reject) => { |
|
const proc = spawn(FFPROBE_PATH, FFPROBE_ARGS) |
|
|
|
const outputBuffers = []; |
|
|
|
proc.stdout.on('data', (data) => { |
|
outputBuffers.push(data); |
|
}); |
|
|
|
proc.stderr.on('data', (data) => { |
|
outputBuffers.push(data); |
|
}); |
|
|
|
proc.on('close', (code) => { |
|
if (code !== 0) { |
|
const msg = `Failed with code = ${code}`; |
|
return reject(new Error(msg)); |
|
} |
|
|
|
const output = outputBuffers.join(''); |
|
|
|
const regex = /(Stream #\d+:\d+.*)/g; |
|
const matches = output.match(regex); |
|
|
|
if (matches) resolve(matches.join('\n')); |
|
|
|
resolve('No stream info found'); |
|
}); |
|
}); |
|
|
|
const props = await createPromise; |
|
|
|
return props; |
|
} |
|
|
|
// 执行Handbrake转码 |
|
async function executeHandbrake(inputFile, outputFile) { |
|
const returnCodeMap = { |
|
0: "操作成功", |
|
1: "操作取消", |
|
2: "无效输入", |
|
3: "初始化失败", |
|
4: "未知的硬件错误", |
|
5: "读取源文件时发生错误或者源文件损坏", |
|
} |
|
|
|
return new Promise((resolve, reject) => { |
|
const command = `HandBrakeCLI.exe -i "${inputFile}" -o "${outputFile}" --preset-import-file "${presetFile}"`; |
|
const proc = exec(command) |
|
|
|
proc.stdout.on('data', data => { |
|
const progress = extractProgress(data); |
|
progress && showProgressBar(progress); |
|
}) |
|
|
|
proc.on('close', code => { |
|
if (code === 0) { |
|
showProgressBar({ prog: 100 }); |
|
resolve(); |
|
} else { |
|
reject(new Error(`HandBrakeCLI exited with code ${code}: ${returnCodeMap[code]}`)); |
|
} |
|
}) |
|
|
|
proc.on('error', error => { |
|
reject(error); |
|
}) |
|
}); |
|
} |
|
|
|
// 解析进度信息 |
|
function extractProgress(output) { |
|
// const regex = /Encoding: task 1 of 1, (\d+\.?\d+) %/; |
|
const regex = /Encoding: task 1 of 1, (?<prog>\d+\.?\d+) %(?<stat>\s+\((?<fps>[\d.]+) fps, avg (?<avg>[\d.]+) fps, ETA (?<eta>[\dhms]+)\))?/; |
|
|
|
const match = output.match(regex); |
|
if (match) return match.groups |
|
|
|
return null; |
|
} |
|
|
|
// 显示进度条 |
|
let lastStat = ''; |
|
function showProgressBar({ prog, stat }) { |
|
lastStat = stat || lastStat; |
|
if (prog === 100) lastStat = ''; |
|
|
|
const progress = parseFloat(prog) / 100 |
|
|
|
const width = 40; // 进度条宽度 |
|
const filledWidth = Math.round(width * progress); |
|
const emptyWidth = width - filledWidth; |
|
const percentage = (progress * 100).toFixed(2); |
|
|
|
const bar = '▓'.repeat(filledWidth) + '░'.repeat(emptyWidth); |
|
|
|
readline.clearLine(process.stdout, 0); |
|
readline.cursorTo(process.stdout, 0); |
|
process.stdout.write(`Progress: [${bar}] ${percentage}%${lastStat}`); |
|
} |
|
|
|
// 计算压缩比例 |
|
function calculateOffRatio(originalSize, compressedSize) { |
|
const ratio = (originalSize - compressedSize) / originalSize * -1; |
|
return `${(ratio < 0 ? '-' : '+')}${Math.abs(ratio * 100).toFixed(2)}%`; |
|
} |
|
|
|
// 字节转换为可读大小 |
|
function bytesToSize(bytes) { |
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; |
|
if (bytes === 0) return 'n/a'; |
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10); |
|
if (i === 0) return `${bytes} ${sizes[i]}`; |
|
return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`; |
|
} |
|
|
|
// 记录错误日志 |
|
async function logError(error) { |
|
const timestamp = new Date().toISOString(); |
|
const logMessage = `${"=".repeat(40)}\n[${timestamp}] ${error}\n`; |
|
console.log(logMessage); |
|
await fse.appendFile(logErr, logMessage, 'utf8'); |
|
} |