Skip to content

Instantly share code, notes, and snippets.

@c-kick
Last active October 19, 2022 08:59
Embed
What would you like to do?
This nodejs script is originally aimed at (Plex) LG OLED users that don't have DTS decoding. It auto-converts audio in video files to something other than DTS (AC3) if applicable (see description in file)
#!/usr/bin/env node
/*
ARC-VT: Audio-stream Reorganizer, Converter & Video Transcoder by Klaas Leussink / hnldesign (C) 2022-2023
This nodejs script is originally aimed at non-DTS LG OLED owners running Plex on a Synology Diskstation.
The problem being that Plex will stubbornly offer the DTS track for playback, producing no sound. And when
asked to convert, playback is choppy/hangs every x seconds. The most solid solution is to make sure the
first available audio stream is a non-DTS (e.g. AC3) codec. This script automates that process:
If no stream with a preferred codec (see `prefs.preferred_audio_codecs`) is present, the
script will convert the first valid (non-commentary) audio stream of a video file to AC3
and set it as the first (default) stream. If a preferred codec is found, but it's not at
the front, it reorders the streams. The output file will be an MP4 container.
The original input file is renamed to an '.old' file extension, so you'll have a backup in case something is wrong.
Optionally, and very alpha: converts video to HEVC if so desired, by using VAAPI (Intel Quick Sync) transcoding
REQUIREMENTS:
- Synology diskstation
- nodejs
- Access to VAAPI device (at /dev/dri/renderD128) - tip: run 'sudo chmod 666 /dev/dri/*' to fix this
- Mediainfo installed (at /volume1/@appstore/mediainfo/bin/mediainfo)
- ffmpeg installed (at /volume1/@appstore/ffmpeg/bin/ffmpeg)
USAGE:
Basic (auto-run, no input required) usage to fix DTS streams:
./arc-vt.js "full/path/to/video.mkv"
Auto transcode video to HEVC if applicable (after basic analysis of input video):
./arc-vt.js "full/path/to/video.mkv" --autotranscode
OPTIONS/SWITCHES:
--transcode <string> transcode audio/video. Requires parameter. Currently, 'video' is the only thing that works.
--bitrate <integer> specify a bitrate to use (falls back to reference bitrates, as specified in 'mbit_thresholds')
--rc_mode <string> specify a rate control mode to be used for HEVC encoding (default is VBR)
--autotranscode <integer> (optional) perform auto transcoding video. Overrides anything set via --transcode, --bitrate and --rc_mode).
Will always use VBR. See 'auto_modes' to define profiles
--force forces re-encoding even if source is already HEVC (ignored when --autotranscode is set)
--audio_idx <integer> force a specific audio stream index to be used as source for a new AC3 stream
-d enable debugging
-t <integer> limits output to <x> seconds. E.g. '-t 60' will output a 1-minute video (useful for debugging).
--confirm confirm every step with y/n (useful in combination with debugging, see below)
--stats show ffmpeg live stats (useful for debugging, but will clutter logs)
--silent only shows errors and info messages if passed
--nohello skips hello-text
--nosubs don't extract subs as separate .srt files, just omit them
example:
./arc-vt.js "full/path/to/video.mkv" --autotranscode 2 --nosubs --confirm -d -t 60
produces a 1-minute HEVC converted file, with DTS fixed, using transcode profile 2, confirms every step and shows a lot of debug info
*/
const process = require('process');
const path = require('path');
const fs = require('fs');
const readline = require('readline');
const child_process = require("child_process");
//mutable debug logger
const logThis = {
showLog : true,
showInfo : true,
showWarn : true,
showErrors : true,
log: function(message) {
if (this.showLog) console.log(message);
},
info: function(message) {
if (this.showInfo) console.info(message);
},
warn: function(message) {
if (this.showWarn) console.warn(message);
},
error: function(message) {
if (this.showErrors) console.error(message);
},
}
// Check write permission on the vaapi device
try {
fs.accessSync('/dev/dri/renderD128', fs.constants.W_OK);
}
catch (err) {
logThis.error(`⛔ Error: Can't access VAAPI device /dev/dri/renderD128 ! Did you chmod it?\n`);
process.exit(1);
return false;
}
//check if mediainfo exists
if (!fs.existsSync('/volume1/@appstore/mediainfo/bin/mediainfo')) {
logThis.error(`⛔ Error: mediainfo not available! (checked path: /volume1/@appstore/mediainfo/bin/mediainfo).\n`);
process.exit(1);
return false;
}
//check if ffmpeg exists
if (!fs.existsSync('/volume1/@appstore/ffmpeg/bin/ffmpeg')) {
logThis.error(`⛔ Error: ffmpeg not available! (checked path: /volume1/@appstore/ffmpeg/bin/ffmpeg).\n`);
process.exit(1);
return false;
}
//check if file was passed
if (!process.argv[2]) {
logThis.error(`⛔ Error: No input file specified.\n`);
process.exit(1);
return false;
}
//make sure ctrl+c throws an error
process.once('uncaughtException', async (err) => {
logThis.error(`\n⛔ ${err}`);
process.exit(0);
})
process.on('SIGINT', function() {
logThis.error('\n⛔ SIGINT');
process.exit(1);
throw new Error();
})
//set up basic constants
const prefs = {
'valid_audio_codecs' : 'aac|mp3|ac3|eac3|dts|opus|vorbis|mp2|mp1|alac|dts-hd|mlp|als|sls|lpcm|dv-audio|amr', //valid as in: can be used for transcoding
'preferred_audio_codecs' : 'ac3|eac3|aac|mp3|mp2|wma|pcm|mpegaudio', //preferred as in: these are allowed in the final output
'valid_rc_modes' : 'ICQ|CQP|VBR|ABR|QVBR',
'valid_subtitle_formats' : 'mov_text|vobsub|ttxt|webvtt',
'extract_subtitles' : 'subrip|utf8',
'image_formats' : 'png|jpg|gif|jpeg|webm|bmp',
'mbit_thresholds' : {
//in mbps
//reference: https://www.dr-lex.be/info-stuff/videocalc.html (-ish)
'mpeg4visual' : {
'UHD' : 48,
'1080p' : 12,
'720p' : 5,
'480p' : 3,
},
'mpegvideo' : {
'UHD' : 48,
'1080p' : 12,
'720p' : 5,
'480p' : 3,
},
'vc1' : {
'UHD' : 48,
'1080p' : 12,
'720p' : 5,
'480p' : 3,
},
'h264' : {
'UHD' : 25,
'1080p' : 8,
'720p' : 4,
'480p' : 2,
},
'avc' : {
'UHD' : 25,
'1080p' : 8,
'720p' : 4,
'480p' : 2,
},
'hevc' : {
'UHD' : 20,
'1080p' : 6,
'720p' : 3,
'480p' : 2,
}
},
'auto_modes' : [ //define auto transcode presets here
// There are three settings per profile:
// qualityThreshold - if the input file has a bitrate below <qualityThreshold>% of the reference bitrate (see 'mbit_thresholds' in prefs) for its resolution, don't proceed
// sizeThreshold - if the resulting file size is still more than <sizeThreshold>% of the input file's size, don't proceed,
// HevcToHevc - convert, even if the source is already hevc
// Profile 0: generic profile - picked if no profile is specified (or of course '--autotranscode 0')
// This profile works well across the spectrum by using weighted averages in quality & size.
// Will trigger at adequate or higher input quality and a size reduction of 25% or more.
{
qualityThreshold: 80,
sizeThreshold: 75,
HevcToHevc: false
},
// Profile 1: more strict on input quality - pick this if you are keen on keeping the highest quality video.
// This will only trigger video transcoding if the input quality is solid and if the space (size) you save will be more than 20%.
{
qualityThreshold: 100,
sizeThreshold: 80,
HevcToHevc: false
},
// Profile 2: more strict on both size and quality reduction - pick this if you are keen on keeping your video files
// as they are: this will only trigger video transcoding if your input file is of solid quality, and if the space (size)
// you save is calculated to be *really* significant: about half the input size, or even more.
// This profile is based on the assumption that h264 to hevc transcoding should *in theory* be 50% more efficient (smaller).
// This profile is recommended if you schedule conversion to be done every <x> days, and you want to make sure files
// aren't duplicate-encoded (HevcToHevc set to false)
{
qualityThreshold: 100,
sizeThreshold: 60,
HevcToHevc: false
}
]
}
const opts = (function(userOptions) {
//process command line options
const defaultOpts = { //defines both which options are valid, and what their defaults are
'--transcode' : '', //transcode <audo/video>, requires parameter. Currently, 'video' is the only thing that works.
'--bitrate' : '', //specify a bitrate to use (falls back to reference bitrates, as specified in 'mbit_thresholds')
'--rc_mode' : '', //specify a rate control mode to be used for HEVC encoding (default is VBR)
'--stats' : false, //show ffmpeg live stats (useful for debugging, but will clutter logs)
'--autotranscode' : false, //perform auto transcoding video, see 'auto_modes' profiles
'--confirm' : false, //confirm every step
'--force' : false, //force re-encoding even if source is already HEVC
'--audio_idx' : '', //force a specific audio stream index to be used as source for a new AC3 stream
'-d' : false, //enable debugging
'-t' : '', //limit output to <x> seconds. E.g. '-t 60' will output a 1-minute video.
'--silent' : false, //only shows errors and info messages if passed
'--nohello' : false, //skip hello
'--nosubs' : false //don't extract subs as separate .srt files, just omit them
}
let userOpts = {};
let skipNext = false;
userOptions.forEach(function(param, index){
if (index > 2) {
if (param[0] === '-' && typeof defaultOpts[param] === 'undefined') {
let didYouMean = Object.keys(defaultOpts).filter((tag) => new RegExp(`\\B${param}\\B`, 'i').test(tag));
logThis.error(`\n⛔ Error: unrecognized parameter "${param}".${(didYouMean.length ? ` Did you mean "${didYouMean[0]}"?` : '')}\n`);
process.exit(1);
} else {
let val = (typeof userOptions[index + 1] !== "undefined" && userOptions[index + 1][0] !== '-') ? userOptions[index + 1] : undefined;
if (!skipNext) {
userOpts[param] = val ? val : (typeof defaultOpts[param] === 'boolean' ? true : '')
}
skipNext = !!val;
}
}
});
return Object.assign(defaultOpts, userOpts);
}(process.argv));
logThis.showLog = !opts['--silent'] || opts['-d'];
let input_file = process.argv[2];
if (path.parse(input_file).dir === '') {
input_file = process.cwd() + '/' + input_file;
}
let working_dir = path.parse(input_file).dir + '/';
let input_json,
fileData = {
numStreams: 0,
fileSize: 0,
fileName: path.parse(input_file).name + path.parse(input_file).ext,
baseName: working_dir + path.parse(input_file).name,
old_filename: working_dir + path.parse(input_file).name + path.parse(input_file).ext,
new_filename: working_dir + path.parse(input_file).name + '.mp4',
temp_file: working_dir + path.parse(input_file).name + '.converting',
backup_file: working_dir + path.parse(input_file).name + '.old',
marker: working_dir + path.parse(input_file).name + '.converted',
cleanUpOnError: [] //array with additional files (subtitles, etc.) to be cleaned up if there is a converting error/abort
}
//functions/prototypes
Object.defineProperty( Array.prototype, 'joinNice', {
//joinNice (c) hnldesign - joins array with comma, but the last two with ampersand. E.g. "1, 2 & 3" or "1 & 2"
value: function() {
return this.length > 1 ? `${this.slice(0, -1).join(', ')} & ${this.pop()}` : this[0];
}
});
function run(command, options, live = false, callback, errCallback) {
try {
if (typeof callback === 'function') {
const childProcess = child_process.spawnSync(command, options,
(live ? {
stdio: ['ignore', 'inherit', 'inherit'], //ignores any 'q' input passing to ffmpeg
shell: true
} : {
shell: true
}),
);
if (childProcess.status > 0 && typeof errCallback === 'function') {
errCallback.call(this, childProcess);
} else {
callback.call(this, childProcess);
}
}
} catch (error) {
if (typeof errCallback === 'function') {
errCallback.call(this, error);
}
}
}
function filterOutStreams(streamGroup){
if (typeof streamGroup !== 'undefined') {
let invalid = [], extract = [], type = 'Unknown';
let streamGroupOut = streamGroup.filter(
function (stream) {
if (stream.extract) extract.push(stream.id);
if (!stream.valid) invalid.push(stream.id);
type = stream.type;
return stream.valid || stream.extract;
}
);
if (invalid.length) {
logThis.log(`- ${type.charAt(0).toUpperCase() + type.slice(1)} ${invalid.length > 1 ? 'streams' : 'stream'} ${invalid.joinNice(', ')} ${invalid.length > 1 ? 'are' : 'is'} not mp4 compatible and will not be in the output file.`);
}
if (extract.length) {
logThis.log(`- SubRip ${type} ${extract.length > 1 ? 'streams' : 'stream'} ${extract.joinNice(', ')} will be extracted as separate .srt files.`);
}
return streamGroupOut;
} else {
return undefined;
}
}
function cleanUpOnExit(error = false){
logThis.info('Cleaning up...');
if (error) {
fileData.cleanUpOnError.forEach(function(file){
if (fs.existsSync(file)) {
fs.unlinkSync(file);
}
})
}
if (fs.existsSync(fileData.temp_file)) {
fs.unlinkSync(fileData.temp_file);
}
logThis.info('Done.\n');
}
function runffmpeg(command) {
logThis.info(`Running ffmpeg... ${opts['--stats'] ? '\n' : ''}`);
//run('/volume1/@appstore/ffmpeg/bin/ffmpeg', command, (opts['--confirm'] || opts['--stats']), function (result) {
run('/volume1/@appstore/ffmpeg/bin/ffmpeg', command, true, function (result) {
logThis.info('Done!\n');
if (opts['--confirm']) {
confirm('Do you want to rename files? y/[n]: ', function (answer) {
if (answer.toLowerCase() === "yes" || answer.toLowerCase() === "y") {
logThis.info(`Renaming files...`);
fs.renameSync(fileData.old_filename, fileData.backup_file);
fs.renameSync(fileData.temp_file, fileData.new_filename);
}
confirm('Do you want to clean up any leftover temporary files? y/[n]: ', function (answer) {
if (answer.toLowerCase() === "yes" || answer.toLowerCase() === "y") {
cleanUpOnExit(true);
}
});
return true;
});
} else {
logThis.info(`Renaming files...`);
fs.renameSync(fileData.old_filename, fileData.backup_file);
fs.renameSync(fileData.temp_file, fileData.new_filename);
cleanUpOnExit();
}
//write marker if video transcoding was requested/enabled
if ((fileData.transcode.video || opts['--autotranscode'])) {
fs.closeSync(fs.openSync(fileData.marker, 'w'));
}
}, function (error) {
logThis.error(`\n⛔ Error ${error.status} ${(error.status === 255) ? 'Likely user abort' : ''}`, (error.stdout ? error.stdout.toString() : ''), (error.stderr ? error.stderr.toString() : ''));
cleanUpOnExit(true);
});
}
function debug(message) {
if (opts['-d']) {
console.log(message);
}
}
function confirm(message, callback) {
let rl = readline.createInterface({
input: process.stdin,
output: process.stdout
}), response;
rl.setPrompt(message);
rl.prompt();
rl.on('line', (userInput) => {
response = userInput;
rl.close();
});
rl.on('close', () => {
return callback(response);
});
}
//hello
if (!opts['--nohello'] && !opts['--silent']) {
logThis.info('\nARC-VT v6.6.19.10.22');
logThis.info('Audio-stream Reorganizer, Converter & Video Transcoder by Klaas Leussink / hnldesign (C) 2022-2023');
logThis.info('=============================================================================================');
logThis.info('This nodejs script is originally aimed at non-DTS LG OLED owners running Plex on a Synology Diskstation. ' +
'If no stream with a preferred codec (see `prefs.preferred_audio_codecs`) is present, ' +
'the script will convert the first valid (non-commentary) audio stream of a video file ' +
'to AC3 and set it as the first (default) stream. If a preferred codec is found, but it\'s ' +
'not at the front, it reorders the streams.\n');
}
//some preliminary feedback
if (opts['--autotranscode'] && opts['--force']) {
logThis.warn(`Notice: --force flag is ignored when --autotranscode is set.\n`);
}
//check if video exists, then run shit.
logThis.log(`Attempting to open file "${fileData.old_filename}"...`);
fs.open(input_file, 'r', function (err) {
if (err) {
logThis.error(`⛔ Error: File "${fileData.old_filename}" doesn't exist!\n`);
process.exit(1);
return false;
} else {
logThis.info(`Analyzing "${fileData.fileName}"...\n`);
run('/volume1/@appstore/mediainfo/bin/mediainfo',['--full', '--output=JSON', `"${input_file}"`],false, function (result) {
input_json = JSON.parse(result.stdout.toString().trim());
//get generic data for file
fileData.numStreams = (parseInt(input_json.media.track[0]["VideoCount"],10) || 0) + (parseInt(input_json.media.track[0]["AudioCount"],10) || 0) + (parseInt(input_json.media.track[0]["TextCount"],10) || 0);
fileData.fileSize = ((( fs.statSync(input_file).size / 1024) / 1024) / 1024); //gets filesize in GB
fileData.format = input_json.media.track[0];
fileData.wantedAudio = false; //default, if video contains a wanted audio track
fileData.validAudio = false; //default, if video contains a wanted audio track at the correct stream position
fileData.moveStreams = false;
fileData.streams = {};
fileData.transcode = {
video: opts['--transcode'].includes('video') || false,
audio: false
}
//When the file has been transcoded (meaning video-wise), a marker-file is present. Check for this when video converting is requested and not forced.
if ((fileData.transcode.video || opts['--autotranscode']) && !opts['--force']) {
if (fs.existsSync(fileData.marker)) {
logThis.info(`\nFile has already been processed.\nHint: remove the \'.converted\' file in the folder of the input file to disable this check and reprocess.\n`);
process.exit(0);
}
}
//parse all streams
for (const [key, value] of Object.entries(input_json.media.track.slice(1))) {
if (! new RegExp(`\\baudio|video|text\\b`, 'i').test(value["@type"].toLowerCase())) continue;
let thisStream = {
id: parseInt(value["ID"], 10),
order: parseInt(value["StreamOrder"], 10),
type: value["@type"].toLowerCase(),
codec: value["Format"].replace(/[^0-9a-z]/gi,'').toLowerCase(),
duration: parseFloat(value["Duration"]),
language: (value["Language"] ? value["Language"].toLowerCase() : '' ),
title: (value["Title"] ? value["Title"] : '' ),
isStill: new RegExp(`\\b${prefs.image_formats}\\b`, 'i').test(value["Format"].toLowerCase()),
streamSize: (((parseFloat(value["StreamSize"]) / 1024) / 1024) / 1024), //get stream's size in GB
convert: false,
truehd: ((value["Title"] ? value["Title"] : '' ).toLowerCase().includes('truehd')) || (value["CodecID"].toLowerCase().includes('truehd')) || (value["Format_Commercial"].toLowerCase().includes('truehd'))
//there's no truehd support in MP4 (without -strict -2), so check if the title suggests this is a truehd stream
};
if (thisStream["isStill"]) {
thisStream["valid"] = false;
} else {
if (thisStream["type"] === "video") {
if (isNaN(thisStream["streamSize"])) {
logThis.warn('Notice: No video stream size available. Falling back to file size (less accurate).');
thisStream.streamSize = fileData.fileSize;
}
thisStream["pixelDensity"] = (parseInt(value["Width"],10) * parseInt(value["Height"],10)) || undefined;
thisStream["resolution"] = ((thisStream["pixelDensity"] > 2073600) ? 'UHD' : ((thisStream["pixelDensity"] >= 921600 && thisStream["pixelDensity"] <= 2073600) ? '1080p' : (thisStream["pixelDensity"] < 408960 ? '480p' : '720p')));
thisStream["HDR"] = (typeof value["HDR_Format"] !== "undefined" && (value["colour_primaries"] === 'BT.2020' || value["colour_primaries"] === 'bt2020'));
thisStream["valid"] = true;
}
if (thisStream["type"] === "video" || thisStream["type"] === "audio") {
if (!value["BitRate"] && value["BitRate_Maximum"]){
logThis.warn(`Notice: falling back to BitRate_Maximum for stream ${value["ID"]} (less accurate).`)
thisStream["bitrate"] = ((parseFloat(value["BitRate_Maximum"]) / 1000) / 1000);
} else {
thisStream["bitrate"] = ((parseFloat(value["BitRate"]) / 1000) / 1000);
}
}
if (thisStream["type"] === "audio") {
thisStream["commentary"] = /commentary/i.test(value["Title"]);
thisStream["valid"] = (new RegExp(`\\b${prefs.valid_audio_codecs}\\b`, 'i').test(thisStream["codec"]) && !thisStream['truehd']);
thisStream["wanted"] = new RegExp(`\\b${prefs.preferred_audio_codecs}\\b`, 'i').test(thisStream["codec"]);
fileData.wantedAudio = (!fileData.wantedAudio ? (thisStream["wanted"] && !thisStream["commentary"]) : fileData.wantedAudio); //flag we found at least one wanted audio stream
}
if (thisStream["type"] === "text") {
thisStream["valid"] = new RegExp(`\\b${prefs.valid_subtitle_formats}\\b`, 'i').test(thisStream["codec"]);
thisStream["extract"] = (opts['--nosubs']) ? false : new RegExp(`\\b${prefs.extract_subtitles}\\b`, 'i').test(thisStream["codec"]);
}
}
//push the stream object to the streams array
(fileData.streams[thisStream["type"]] = fileData.streams[thisStream["type"]] ? fileData.streams[thisStream["type"]] : []).push(thisStream);
}
//process audio streams to see what needs to be done
let newAudioStreams = [];
for (const [key, value] of Object.entries(fileData.streams.audio)) {
let thisStream = Object.assign({}, value);
let idx = parseInt(opts['--audio_idx'],10);
if (thisStream.commentary && fileData.validAudio) {
logThis.log(`- Audio stream index ${thisStream.id} has a valid codec (${thisStream.codec}), but is highly likely a commentary track, so it was ignored.`);
}
if (thisStream.wanted && key < 1 && isNaN(idx)) {
logThis.log(`- First audio stream is of a preferred codec (${thisStream.codec}). No audio stream changes necessary.`);
//first audio stream is of valid type
newAudioStreams.push(thisStream);
//set flag
fileData.validAudio = true;
} else if ((!fileData.wantedAudio && !thisStream.commentary && !fileData.validAudio) || (!isNaN(idx) && thisStream.id === idx)) {
if (!isNaN(idx) && thisStream.id === idx) {
logThis.log(`- Requested audio stream index (${idx}, ${thisStream.title} (${thisStream.language}), ${thisStream.codec}) will be used as source for new ac3 stream.`);
} else {
logThis.log(`- There is no preferred audio stream present, stream ${thisStream.id} (${thisStream.codec}) will be used as a source for creating a new ac3 stream`);
}
//there is no valid audio stream in the entire file, pick this one as a source
thisStream.valid = true;
thisStream.convert = true;
thisStream.target_codec = 'ac3';
thisStream.target_bitrate = '640K';
thisStream.name = 'AC3 (converted)';
//flag audio transcoding
fileData.transcode.audio = true;
//flag as solved
fileData.validAudio = true;
//keep the original audio stream
let original = Object.assign({}, value);
newAudioStreams.push(original);
//set stream to be converted as a new, first stream
newAudioStreams.unshift(thisStream);
} else if (thisStream.wanted && key > 0 && !fileData.moveStreams && !fileData.validAudio) {
logThis.log(`- Audio stream ${thisStream.id} is of a preferred codec (${thisStream.codec})\n but is at the wrong index (${key}). Must be set as the first audio track.`);
//this is the first wanted stream, but it is not set the first audio stream, so it should be shifted to the front
newAudioStreams.unshift(thisStream);
fileData.moveStreams = true;
} else {
//keep all streams
newAudioStreams.push(thisStream);
}
}
fileData.streams.audio = newAudioStreams;
//filter out invalid streams. Do this after setting source tracks for a potentially new ac3, or else we end up with no audio tracks
if (typeof fileData.streams.video !== 'undefined') {
fileData.streams.video = filterOutStreams(fileData.streams.video);
}
if (typeof fileData.streams.audio !== 'undefined') {
fileData.streams.audio = filterOutStreams(fileData.streams.audio);
}
if (typeof fileData.streams.text !== 'undefined') {
fileData.streams.text = filterOutStreams(fileData.streams.text);
}
//see what needs to be done when transcoding video
if (fileData.transcode.video || opts['--autotranscode']) {
if (fileData.streams.video[0].codec.toLowerCase() === 'hevc' && !opts['--force'] && !opts['--autotranscode']) {
logThis.warn('- Note: input video is already hevc! Not re-encoding. Overrule this by passing --force');
fileData.transcode.video = false;
} else if (opts['--autotranscode']) {
if (typeof opts['--autotranscode'] === 'boolean') {
opts['--autotranscode'] = 0; //picks first profile if only --autotranscode was passed
}
if (typeof prefs.auto_modes[opts['--autotranscode']] === 'undefined') {
logThis.error(`\n⛔ Error: Cannot run automode: profile (${opts['--autotranscode']}) doesn't exist!\n`);
process.exit(1);
return false;
} else if (!prefs.mbit_thresholds[fileData.streams.video[0].codec]) {
logThis.error(`\n⛔ Error: Cannot run automode: no bitrate references defined for codec "${fileData.streams.video[0].codec}"`);
process.exit(1);
return false;
} else if (!prefs.mbit_thresholds[fileData.streams.video[0].codec][fileData.streams.video[0].resolution]) {
logThis.error(`\n⛔ Error: Cannot run automode: no bitrate references defined for "${fileData.streams.video[0].codec}" @ ${fileData.streams.video[0].resolution}`);
process.exit(1);
return false;
} else {
let autoProfile = prefs.auto_modes[opts['--autotranscode']];
logThis.log(`\nRunning auto mode using profile ${opts['--autotranscode']}...`);
for (const [setting, value] of Object.entries(autoProfile)) {
logThis.log(`- ${setting}:`, `${value}`);
}
let refMbps = prefs.mbit_thresholds[fileData.streams.video[0].codec][fileData.streams.video[0].resolution];
let tarMbps = prefs.mbit_thresholds['hevc'][fileData.streams.video[0].resolution];
let voutSize = (tarMbps * (fileData.streams.video[0].duration / 60) * 0.0075);
let inSizeExVideo = (fileData.fileSize - fileData.streams.video[0].streamSize);
let outSize = (inSizeExVideo + voutSize);
let inQuality = ((fileData.streams.video[0].bitrate /refMbps) * 100);
let outSizeDiff = ((outSize / fileData.fileSize) * 100);
let go_quality = (inQuality > autoProfile.qualityThreshold);
let go_size = (outSizeDiff <= autoProfile.sizeThreshold);
let go_codec = autoProfile.HevcToHevc || (fileData.streams.video[0].codec !== 'hevc');
logThis.log('\n ============== Automatic mode =============');
logThis.log(` Resolution: ${fileData.streams.video[0].resolution}`);
logThis.log(` Ref. bitrate for ${fileData.streams.video[0].codec} (input): ${refMbps} Mbps`);
logThis.log(` Ref. bitrate for hevc (output): ${tarMbps} Mbps`);
logThis.log('\n --------------- Input file ----------------');
logThis.log(` ->| File size: ${fileData.fileSize.toFixed(3)} GB`);
logThis.log(` ->| Without video: ${inSizeExVideo.toFixed(3)} GB`);
logThis.log(` ->| Video stream size: ${(fileData.streams.video[0].streamSize.toFixed(3))} GB`);
logThis.log(` ->| Video bitrate: ${fileData.streams.video[0].bitrate.toFixed(2)} Mbps`);
logThis.log(` ->| Video duration: ${(fileData.streams.video[0].duration / 60).toFixed(2)} m`);
logThis.log('\n -------------- Output file ----------------');
logThis.log(` |-> Est. size: ~${outSize.toFixed(2)} GB`);
logThis.log(` |-> Video stream size: ~${voutSize.toFixed(2)} GB`);
logThis.log(` |-> Video bitrate: ${tarMbps.toFixed(2)} Mbps`);
logThis.log('\n ---------------- Analysis -----------------');
logThis.log(` ->| In quality*: [${go_quality ? '✅' : '❌'}]\t\t${inQuality.toFixed(1)}% ${(go_quality ? '>' : '<')} ${autoProfile.qualityThreshold}%`);
logThis.log(` |-> Out size: [${go_size ? '✅' : '❌'}]\t\t${outSizeDiff.toFixed(1)}% ${(go_size ? '<' : '>')} ${autoProfile.sizeThreshold}%`);
logThis.log(` >-> Conversion: [${autoProfile.HevcToHevc ? 'F' : (go_codec ? '✅' : '❌')}]\t\t${fileData.streams.video[0].codec} -> hevc`);
logThis.log('\n ---------------- Conclusion ---------------');
logThis.log(`${opts['-d'] ? ' ' : '-'} Transcode video? ${(go_quality && go_size && go_codec) ? `Yes:\n Input quality sufficient (${inQuality.toFixed(1)}%)\n Size reduction significant (${outSizeDiff.toFixed(1)}% of input).` :
`No${(!go_quality ? ': input quality too low.' : (!go_size ? ': size difference not big enough.' : (!go_codec ? ': input is already hevc.' : '.')))}`}`);
logThis.log(`\n * of reference bitrate for the input codec (${fileData.streams.video[0].codec})`);
if ((go_quality && go_size && go_codec)) {
fileData.transcode.video = true;
fileData.streams.video[0].convert = true;
fileData.streams.video[0].target_codec = 'hevc_vaapi';
fileData.streams.video[0].rc_mode = 'vbr';
fileData.streams.video[0].target_bitrate = tarMbps + 'M';
}
}
} else {
if (opts['--force'] && fileData.streams.video[0].codec.toLowerCase() === 'hevc') {
logThis.warn('- Note: input video is already hevc, but re-encoding has been forced.');
}
fileData.streams.video[0].convert = true;
fileData.streams.video[0].target_codec = 'hevc_vaapi';
fileData.streams.video[0].rc_mode = 'vbr';
if (!opts['--rc_mode']) {
logThis.log(`- Video transcoding is enabled, but no rc mode given. Defaulting to VBR`);
} else if (new RegExp(`\\b${prefs.valid_rc_modes}\\b`, 'i').test(opts['--rc_mode'].toUpperCase().trim())) {
fileData.streams.video[0].rc_mode = opts['--rc_mode'];
}
if (!opts['--bitrate']) {
fileData.streams.video[0].target_bitrate = Number((fileData.streams.video[0].bitrate / 2).toFixed(2)) + 'M';
logThis.log(`- Video transcoding is enabled, but no bitrate given. Defaulting to the hevc reference bitrate @ ${fileData.streams.video[0].resolution} (${prefs.mbit_thresholds['hevc'][fileData.streams.video[0].resolution]} Mbps)`);
} else {
fileData.streams.video[0].target_bitrate = parseInt(opts['--bitrate'],10) + 'M';
}
}
}
if (!fileData.moveStreams && !fileData.transcode.video && !fileData.transcode.audio) {
logThis.info('\nDone: nothing to do for this file.\n');
return true;
}
//build stream map
const stream_map = []; let new_stream_id = 0, sub_idx = 0;
for (const [type, streams] of Object.entries(fileData.streams)) {
streams.every(function(stream) {
if (stream.extract) {
//stream_map.unshift('-map', `0:${stream.order}`, `"${fileData.baseName}.${stream.language}.${sub_idx}.srt"`);
stream_map.unshift('-map', `0:${stream.order}`, `"${fileData.baseName}.${stream.language}.srt"`);
fileData.cleanUpOnError.push(`${fileData.baseName}.${stream.language}.srt`);
if (opts['-t'] && sub_idx < 1) {
logThis.log('- Note: -t was passed to limit the output duration, though due to the extraction of srt files, the entire video must be processed.');
//todo: opt out extraction?
}
sub_idx++;
} else if (stream.convert) {
stream_map.push('-map', `0:${stream.order}`, `-c:${new_stream_id}`, stream.target_codec, `-b:${new_stream_id}`, stream.target_bitrate);
if (typeof stream.name !== "undefined") {
stream_map.push(`-metadata:s:${stream.order}`, `title="${stream.name}",language=${stream.language}`);
}
if (stream.type === 'video') {
stream_map.push('-rc_mode', stream.rc_mode.toUpperCase());
}
} else {
stream_map.push('-map', `0:${stream.order}`, `-c:${new_stream_id}`, 'copy');
}
new_stream_id++;
return true;
});
}
let base_options = ['-v', 'error', (opts['--stats'] ? '-stats' : ''), '-y'];
if (sub_idx) {
base_options.push('-sub_charenc', 'UTF-8')
}
if (opts['-t'] !== '' && parseInt(opts['-t'], 10)) {
base_options.push('-t', parseInt(opts['-t'], 10).toString());
}
if (fileData.transcode.video) {
if (new RegExp(`\\bhevc|265\\b`, 'i').test(fileData.streams.video[0].codec.toLowerCase())) {
//stream_map.push('-filter_complex', '\'[0:0]format=nv12|vaapi,hwupload,scale_vaapi,hwmap=mode=write,format=nv12,hwmap\'');
}
if (fileData.streams.video[0]["HDR"]) {
stream_map.push('-profile:v', 'main10', '-sei', 'hdr', '-max_muxing_queue_size', '9999', '-color_primaries',
'bt2020', '-color_trc', 'smpte2084', '-colorspace', 'bt2020nc', '-pix_fmt', 'yuv420p10le');
} else {
stream_map.push('-profile:v', 'main');
}
base_options.push('-vtag' ,'hvc1', '-fflags', '+genpts',
'-vaapi_device', '/dev/dri/renderD128',
'-init_hw_device', 'vaapi=IntelQS:/dev/dri/renderD128',
'-hwaccel', 'vaapi',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi');
}
base_options.push('-i', `"${input_file}"`);
if (fileData.transcode.video) {
base_options.push('-filter_hw_device', 'IntelQS');
}
stream_map.push('-f', 'mp4', `"${fileData.temp_file}"`);
logThis.log('\nDone. Ready to run ffmpeg.');
debug('\n--- Options ---');
debug(opts);
debug('\n--- Stream settings ---');
debug(fileData.streams);
debug('\n--- Transcode ---');
debug(fileData.transcode);
debug('\n--- ffmpeg command ---');
debug(base_options.concat(stream_map).join(' ').replace(/\s-/g, '\n-') + '\n');
if (opts['--confirm']) {
confirm('\nContinue? y/[n]: ', function(answer){
if(answer.toLowerCase() === "yes" || answer.toLowerCase() === "y") {
runffmpeg(base_options.concat(stream_map));
return true;
} else {
logThis.log ('User aborted.\n');
return false;
}
})
} else {
runffmpeg(base_options.concat(stream_map));
}
});
return true;
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment