/*jshint node:true*/ 'use strict'; var exec = require('child_process').exec; 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; } }; } };