audiobookshelf/server/libs/fluentFfmpeg/utils.js

455 lines
13 KiB
JavaScript

/*jshint node:true*/
'use strict';
var isWindows = require('os').platform().match(/win(32|64)/);
var which = require('../which');
var nlRegexp = /\r\n|\r|\n/g;
var streamRegexp = /^\[?(.*?)\]?$/;
var filterEscapeRegexp = /[,]/;
var whichCache = {};
/**
* Parse progress line from ffmpeg stderr
*
* @param {String} line progress line
* @return progress object
* @private
*/
function parseProgressLine(line) {
var progress = {};
// Remove all spaces after = and trim
line = line.replace(/=\s+/g, '=').trim();
var progressParts = line.split(' ');
// Split every progress part by "=" to get key and value
for (var i = 0; i < progressParts.length; i++) {
var progressSplit = progressParts[i].split('=', 2);
var key = progressSplit[0];
var value = progressSplit[1];
// This is not a progress line
if (typeof value === 'undefined')
return null;
progress[key] = value;
}
return progress;
}
var utils = module.exports = {
isWindows: isWindows,
streamRegexp: streamRegexp,
/**
* Copy an object keys into another one
*
* @param {Object} source source object
* @param {Object} dest destination object
* @private
*/
copy: function (source, dest) {
Object.keys(source).forEach(function (key) {
dest[key] = source[key];
});
},
/**
* Create an argument list
*
* Returns a function that adds new arguments to the list.
* It also has the following methods:
* - clear() empties the argument list
* - get() returns the argument list
* - find(arg, count) finds 'arg' in the list and return the following 'count' items, or undefined if not found
* - remove(arg, count) remove 'arg' in the list as well as the following 'count' items
*
* @private
*/
args: function () {
var list = [];
// Append argument(s) to the list
var argfunc = function () {
if (arguments.length === 1 && Array.isArray(arguments[0])) {
list = list.concat(arguments[0]);
} else {
list = list.concat([].slice.call(arguments));
}
};
// Clear argument list
argfunc.clear = function () {
list = [];
};
// Return argument list
argfunc.get = function () {
return list;
};
// Find argument 'arg' in list, and if found, return an array of the 'count' items that follow it
argfunc.find = function (arg, count) {
var index = list.indexOf(arg);
if (index !== -1) {
return list.slice(index + 1, index + 1 + (count || 0));
}
};
// Find argument 'arg' in list, and if found, remove it as well as the 'count' items that follow it
argfunc.remove = function (arg, count) {
var index = list.indexOf(arg);
if (index !== -1) {
list.splice(index, (count || 0) + 1);
}
};
// Clone argument list
argfunc.clone = function () {
var cloned = utils.args();
cloned(list);
return cloned;
};
return argfunc;
},
/**
* Generate filter strings
*
* @param {String[]|Object[]} filters filter specifications. When using objects,
* each must have the following properties:
* @param {String} filters.filter filter name
* @param {String|Array} [filters.inputs] (array of) input stream specifier(s) for the filter,
* defaults to ffmpeg automatically choosing the first unused matching streams
* @param {String|Array} [filters.outputs] (array of) output stream specifier(s) for the filter,
* defaults to ffmpeg automatically assigning the output to the output file
* @param {Object|String|Array} [filters.options] filter options, can be omitted to not set any options
* @return String[]
* @private
*/
makeFilterStrings: function (filters) {
return filters.map(function (filterSpec) {
if (typeof filterSpec === 'string') {
return filterSpec;
}
var filterString = '';
// Filter string format is:
// [input1][input2]...filter[output1][output2]...
// The 'filter' part can optionaly have arguments:
// filter=arg1:arg2:arg3
// filter=arg1=v1:arg2=v2:arg3=v3
// Add inputs
if (Array.isArray(filterSpec.inputs)) {
filterString += filterSpec.inputs.map(function (streamSpec) {
return streamSpec.replace(streamRegexp, '[$1]');
}).join('');
} else if (typeof filterSpec.inputs === 'string') {
filterString += filterSpec.inputs.replace(streamRegexp, '[$1]');
}
// Add filter
filterString += filterSpec.filter;
// Add options
if (filterSpec.options) {
if (typeof filterSpec.options === 'string' || typeof filterSpec.options === 'number') {
// Option string
filterString += '=' + filterSpec.options;
} else if (Array.isArray(filterSpec.options)) {
// Option array (unnamed options)
filterString += '=' + filterSpec.options.map(function (option) {
if (typeof option === 'string' && option.match(filterEscapeRegexp)) {
return '\'' + option + '\'';
} else {
return option;
}
}).join(':');
} else if (Object.keys(filterSpec.options).length) {
// Option object (named options)
filterString += '=' + Object.keys(filterSpec.options).map(function (option) {
var value = filterSpec.options[option];
if (typeof value === 'string' && value.match(filterEscapeRegexp)) {
value = '\'' + value + '\'';
}
return option + '=' + value;
}).join(':');
}
}
// Add outputs
if (Array.isArray(filterSpec.outputs)) {
filterString += filterSpec.outputs.map(function (streamSpec) {
return streamSpec.replace(streamRegexp, '[$1]');
}).join('');
} else if (typeof filterSpec.outputs === 'string') {
filterString += filterSpec.outputs.replace(streamRegexp, '[$1]');
}
return filterString;
});
},
/**
* Search for an executable
*
* Uses 'which' or 'where' depending on platform
*
* @param {String} name executable name
* @param {Function} callback callback with signature (err, path)
* @private
*/
which: function (name, callback) {
if (name in whichCache) {
return callback(null, whichCache[name]);
}
which(name, function (err, result) {
if (err) {
// Treat errors as not found
return callback(null, whichCache[name] = '');
}
callback(null, whichCache[name] = result);
});
},
/**
* Convert a [[hh:]mm:]ss[.xxx] timemark into seconds
*
* @param {String} timemark timemark string
* @return Number
* @private
*/
timemarkToSeconds: function (timemark) {
if (typeof timemark === 'number') {
return timemark;
}
if (timemark.indexOf(':') === -1 && timemark.indexOf('.') >= 0) {
return Number(timemark);
}
var parts = timemark.split(':');
// add seconds
var secs = Number(parts.pop());
if (parts.length) {
// add minutes
secs += Number(parts.pop()) * 60;
}
if (parts.length) {
// add hours
secs += Number(parts.pop()) * 3600;
}
return secs;
},
/**
* Extract codec data from ffmpeg stderr and emit 'codecData' event if appropriate
* Call it with an initially empty codec object once with each line of stderr output until it returns true
*
* @param {FfmpegCommand} command event emitter
* @param {String} stderrLine ffmpeg stderr output line
* @param {Object} codecObject object used to accumulate codec data between calls
* @return {Boolean} true if codec data is complete (and event was emitted), false otherwise
* @private
*/
extractCodecData: function (command, stderrLine, codecsObject) {
var inputPattern = /Input #[0-9]+, ([^ ]+),/;
var durPattern = /Duration\: ([^,]+)/;
var audioPattern = /Audio\: (.*)/;
var videoPattern = /Video\: (.*)/;
if (!('inputStack' in codecsObject)) {
codecsObject.inputStack = [];
codecsObject.inputIndex = -1;
codecsObject.inInput = false;
}
var inputStack = codecsObject.inputStack;
var inputIndex = codecsObject.inputIndex;
var inInput = codecsObject.inInput;
var format, dur, audio, video;
if (format = stderrLine.match(inputPattern)) {
inInput = codecsObject.inInput = true;
inputIndex = codecsObject.inputIndex = codecsObject.inputIndex + 1;
inputStack[inputIndex] = { format: format[1], audio: '', video: '', duration: '' };
} else if (inInput && (dur = stderrLine.match(durPattern))) {
inputStack[inputIndex].duration = dur[1];
} else if (inInput && (audio = stderrLine.match(audioPattern))) {
audio = audio[1].split(', ');
inputStack[inputIndex].audio = audio[0];
inputStack[inputIndex].audio_details = audio;
} else if (inInput && (video = stderrLine.match(videoPattern))) {
video = video[1].split(', ');
inputStack[inputIndex].video = video[0];
inputStack[inputIndex].video_details = video;
} else if (/Output #\d+/.test(stderrLine)) {
inInput = codecsObject.inInput = false;
} else if (/Stream mapping:|Press (\[q\]|ctrl-c) to stop/.test(stderrLine)) {
command.emit.apply(command, ['codecData'].concat(inputStack));
return true;
}
return false;
},
/**
* Extract progress data from ffmpeg stderr and emit 'progress' event if appropriate
*
* @param {FfmpegCommand} command event emitter
* @param {String} stderrLine ffmpeg stderr data
* @private
*/
extractProgress: function (command, stderrLine) {
var progress = parseProgressLine(stderrLine);
if (progress) {
// build progress report object
var ret = {
frames: parseInt(progress.frame, 10),
currentFps: parseInt(progress.fps, 10),
currentKbps: progress.bitrate ? parseFloat(progress.bitrate.replace('kbits/s', '')) : 0,
targetSize: parseInt(progress.size || progress.Lsize, 10),
timemark: progress.time
};
// calculate percent progress using duration
if (command._ffprobeData && command._ffprobeData.format && command._ffprobeData.format.duration) {
var duration = Number(command._ffprobeData.format.duration);
if (!isNaN(duration))
ret.percent = (utils.timemarkToSeconds(ret.timemark) / duration) * 100;
}
command.emit('progress', ret);
}
},
/**
* Extract error message(s) from ffmpeg stderr
*
* @param {String} stderr ffmpeg stderr data
* @return {String}
* @private
*/
extractError: function (stderr) {
// Only return the last stderr lines that don't start with a space or a square bracket
return stderr.split(nlRegexp).reduce(function (messages, message) {
if (message.charAt(0) === ' ' || message.charAt(0) === '[') {
return [];
} else {
messages.push(message);
return messages;
}
}, []).join('\n');
},
/**
* Creates a line ring buffer object with the following methods:
* - append(str) : appends a string or buffer
* - get() : returns the whole string
* - close() : prevents further append() calls and does a last call to callbacks
* - callback(cb) : calls cb for each line (incl. those already in the ring)
*
* @param {Numebr} maxLines maximum number of lines to store (<= 0 for unlimited)
*/
linesRing: function (maxLines) {
var cbs = [];
var lines = [];
var current = null;
var closed = false
var max = maxLines - 1;
function emit(line) {
cbs.forEach(function (cb) { cb(line); });
}
return {
callback: function (cb) {
lines.forEach(function (l) { cb(l); });
cbs.push(cb);
},
append: function (str) {
if (closed) return;
if (str instanceof Buffer) str = '' + str;
if (!str || str.length === 0) return;
var newLines = str.split(nlRegexp);
if (newLines.length === 1) {
if (current !== null) {
current = current + newLines.shift();
} else {
current = newLines.shift();
}
} else {
if (current !== null) {
current = current + newLines.shift();
emit(current);
lines.push(current);
}
current = newLines.pop();
newLines.forEach(function (l) {
emit(l);
lines.push(l);
});
if (max > -1 && lines.length > max) {
lines.splice(0, lines.length - max);
}
}
},
get: function () {
if (current !== null) {
return lines.concat([current]).join('\n');
} else {
return lines.join('\n');
}
},
close: function () {
if (closed) return;
if (current !== null) {
emit(current);
lines.push(current);
if (max > -1 && lines.length > max) {
lines.shift();
}
current = null;
}
closed = true;
}
};
}
};