Skip to content

Instantly share code, notes, and snippets.

@journey-ad
Last active June 16, 2023 13:59
Show Gist options
  • Save journey-ad/6fb372486c1c9e16f6e047c2e174a0f0 to your computer and use it in GitHub Desktop.
Save journey-ad/6fb372486c1c9e16f6e047c2e174a0f0 to your computer and use it in GitHub Desktop.
零散视频批量转码压缩

这是一个用来遍历目录下所有视频类文件,并将其统一转换为mp4格式的nodejs脚本,支持自动跳过已处理文件,支持显示进度信息
文件名和目录结构不会发生变化,如果转码后的视频体积相比原视频更小,原始文件将被删除,否则将保留原始文件

依赖

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');
}
{
"PresetList": [
{
"AlignAVStart": true,
"AudioCopyMask": [
"copy:aac",
"copy:ac3",
"copy:dtshd",
"copy:dts",
"copy:mp3",
"copy:truehd",
"copy:flac",
"copy:eac3"
],
"AudioEncoderFallback": "ac3",
"AudioLanguageList": [],
"AudioList": [
{
"AudioBitrate": 160,
"AudioCompressionLevel": 0,
"AudioEncoder": "av_aac",
"AudioMixdown": "stereo",
"AudioNormalizeMixLevel": false,
"AudioSamplerate": "auto",
"AudioTrackQualityEnable": false,
"AudioTrackQuality": -1,
"AudioTrackGainSlider": 0,
"AudioTrackDRCSlider": 0
}
],
"AudioSecondaryEncoderMode": true,
"AudioTrackSelectionBehavior": "first",
"ChapterMarkers": false,
"ChildrenArray": [],
"Default": true,
"FileFormat": "av_mp4",
"Folder": false,
"FolderOpen": false,
"Mp4HttpOptimize": false,
"Mp4iPodCompatible": false,
"PictureCropMode": 0,
"PictureBottomCrop": 0,
"PictureLeftCrop": 0,
"PictureRightCrop": 0,
"PictureTopCrop": 0,
"PictureDARWidth": 853,
"PictureDeblockPreset": "off",
"PictureDeblockTune": "medium",
"PictureDeblockCustom": "strength=strong:thresh=20:blocksize=8",
"PictureDeinterlaceFilter": "decomb",
"PictureCombDetectPreset": "default",
"PictureCombDetectCustom": "",
"PictureDeinterlacePreset": "default",
"PictureDeinterlaceCustom": "",
"PictureDenoiseCustom": "",
"PictureDenoiseFilter": "off",
"PictureSharpenCustom": "",
"PictureSharpenFilter": "off",
"PictureSharpenPreset": "medium",
"PictureSharpenTune": "none",
"PictureDetelecine": "off",
"PictureDetelecineCustom": "",
"PictureColorspacePreset": "off",
"PictureColorspaceCustom": "",
"PictureChromaSmoothPreset": "off",
"PictureChromaSmoothTune": "none",
"PictureChromaSmoothCustom": "",
"PictureItuPAR": false,
"PictureKeepRatio": true,
"PicturePAR": "auto",
"PicturePARWidth": 32,
"PicturePARHeight": 27,
"PictureWidth": 1920,
"PictureHeight": 1080,
"PictureUseMaximumSize": true,
"PictureAllowUpscaling": false,
"PictureForceHeight": 0,
"PictureForceWidth": 0,
"PicturePadMode": "none",
"PicturePadTop": 0,
"PicturePadBottom": 0,
"PicturePadLeft": 0,
"PicturePadRight": 0,
"PresetName": "1080p30",
"Type": 1,
"SubtitleAddCC": false,
"SubtitleAddForeignAudioSearch": true,
"SubtitleAddForeignAudioSubtitle": false,
"SubtitleBurnBehavior": "foreign",
"SubtitleBurnBDSub": false,
"SubtitleBurnDVDSub": false,
"SubtitleLanguageList": [],
"SubtitleTrackSelectionBehavior": "none",
"VideoAvgBitrate": 0,
"VideoColorMatrixCode": 0,
"VideoEncoder": "x264",
"VideoFramerateMode": "cfr",
"VideoGrayScale": false,
"VideoScaler": "swscale",
"VideoPreset": "slow",
"VideoTune": "",
"VideoProfile": "main",
"VideoLevel": "4.0",
"VideoOptionExtra": "",
"VideoQualityType": 2,
"VideoQualitySlider": 22,
"VideoTwoPass": true,
"VideoTurboTwoPass": true,
"x264UseAdvancedOptions": false,
"PresetDisabled": false,
"MetadataPassthrough": true
}
],
"VersionMajor": 50,
"VersionMicro": 0,
"VersionMinor": 0
}
{
"dependencies": {
"edge-js": "^19.3.0",
"fs-extra": "^11.1.1"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment