Source: ffmpeg.js

/*jshint node:true*/
'use strict';

import { EventEmitter } from 'events';
import { spawn } from 'child_process';

import { setOrClear } from './utils';
import FfmpegInput from './ffmpegInput';
import FfmpegOutput from './ffmpegOutput';

let ffmpegBinaryPath = 'ffmpeg';
const nlRegexp = /\r\n|\r|\n/g;

/**
 * A FFmpeg helper class
 *
 * This provides a nicer interface for building & running ffmpeg commands.
 * As a general run each function called runs on the last input / ouput addec
 *
 * @returns {FfmpegCommand}
 * @constructor
 */
class FfmpegCommand extends EventEmitter {
	/**
	 *
	 */
	constructor() {
		super();

		this._inputs = [];
		this._outputs = [];

		this._options = {};
	}

	static ffmpegBinary(path) {
		if (path) {
			ffmpegBinaryPath = path;
		}

		return ffmpegBinaryPath;
	}

	/**
	 *
	 * @param file
	 * @param options
	 * @returns {FfmpegInput}
	 */
	input(file, options) {
		let ret = new FfmpegInput(this, file, options);
		this._inputs.push(ret);
		return ret;
	}

	/**
	 *
	 * @param file
	 * @param options
	 * @returns {FfmpegOutput}
	 */
	output(file, options) {
		let ret = new FfmpegOutput(this, file, options);
		this._outputs.push(ret);
		return ret;
	}

	/**
	 *
	 * @param strictMode
	 * @returns {FfmpegCommand}
	 */
	strictMode(strictMode) {
		if (strictMode) {
			this._options['-strict'] = ['-strict', '-2'];
		} else {
			delete this._options['-strict'];
		}

		return this;
	}

	/**
	 *
	 * @param value
	 * @returns {FfmpegCommand}
	 */
	overwrite(value) {
		if (value) {
			this._options['-y'] = ['-y'];
		} else {
			delete this._options['-y'];
		}

		return this;
	}

	/**
	 *
	 * @param complexFilter
	 * @returns {FfmpegCommand}
	 */
	complexFilter(complexFilter) {
		if (Array.isArray(complexFilter)) {
			complexFilter = complexFilter.join(';');
		}

		setOrClear(this._options, '-filter_complex', complexFilter);

		return this;
	}

	/**
	 *
	 * @returns {*|Array.<T>}
	 */
	get args() {
		return [].concat(
			this._inputs.reduce((args, input) => args.concat(input.inputArgs), []),

			Object.keys(this._options).reduce((args, current) => args.concat(this._options[current]), []),

			this._outputs.reduce((args, output) => args.concat(output.outputArgs), [])
		);
	}

	/**
	 *
	 * @emits FfmpegCommand#error
	 * @emits FfmpegCommand#end
	 * @emits FfmpegCommand#start
	 * @emits FfmpegCommand#progress
	 * @returns {FfmpegCommand}
	 */
	run() {
		let self = this;

		let stdout = '';
		let stdoutClosed = false;

		let stderr = '';
		let stderrClosed = false;

		let processExited = false;

		// Ensure we send 'end' or 'error' only once
		let ended = false;

		function emitEnd(err) {
			if (ended) {
				return;
			}

			if (err) {
				ended = true;

				if (err.message.match(/ffmpeg exited with code/)) {
					// Add ffmpeg error message
					err.message += ': ' + FfmpegCommand.extractError(stderr);
				}

				self.emit('error', err);
			} else if (processExited && stdoutClosed && stderrClosed) {
				ended = true;

				self.emit('end');
			}
		}

		let args = this.args;
		stdout += FfmpegCommand.ffmpegBinary() + ' ' + args.join(' ') + '\n';
		this.ffmpegProc = spawn(FfmpegCommand.ffmpegBinary(), args);
		this.emit('start', FfmpegCommand.ffmpegBinary() + ' ' + args.join(' '));

		if (this.ffmpegProc.stderr) {
			this.ffmpegProc.stderr.setEncoding('utf8');
		}

		this.ffmpegProc.on('error', emitEnd);

		// Handle process exit
		this.ffmpegProc.on('exit', (code, signal) => {
			processExited = true;

			if (signal) {
				emitEnd(new Error('ffmpeg was killed with signal ' + signal));
			} else if (code) {
				emitEnd(new Error('ffmpeg exited with code ' + code));
			} else {
				emitEnd();
			}
		});

		this.ffmpegProc.stdout.on('data', (data) => {
			stdout += data;
		});

		this.ffmpegProc.stdout.on('close', () => {
			stdoutClosed = true;
			emitEnd();
		});

		this.ffmpegProc.stderr.on('data', (data) => {
			stderr += data;

			let progress = FfmpegCommand.extractProgress(data);
			if (progress) {
				this.emit('progress', progress);
			}
		});

		this.ffmpegProc.stderr.on('close', () => {
			stderrClosed = true;
			emitEnd();
		});

		return this;
	}

	/**
	 *
	 * @param signal
	 * @returns {FfmpegCommand}
	 */
	kill(signal) {
		if (this.ffmpegProc) {
			this.ffmpegProc.kill(signal || 'SIGKILL');
		}

		return this;
	}

	/**
	 * Extract error message(s) from ffmpeg stderr
	 *
	 * @param {String} stderr ffmpeg stderr data
	 * @return {String}
	 * @private
	 */
	static extractError(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');
	}

	/**
	 * Extract progress data from ffmpeg stderr and emit 'progress' event if appropriate
	 *
	 * @param {String} stderr ffmpeg stderr data
	 * @private
	 */
	static extractProgress(stderr) {
		let lines = stderr.split(nlRegexp);
		let lastLine = lines[lines.length - 2];
		let progress = {};

		if (!lastLine) {
			return null;
		}

		// Remove all spaces after = and trim
		lastLine = lastLine.replace(/=\s+/g, '=').trim();
		let progressParts = lastLine.split(' ');

		// Split every progress part by "=" to get key and value
		for (var i = 0; i < progressParts.length; i++) {
			let progressSplit = progressParts[i].split('=', 2);

			// This is not a progress line
			if (typeof progressSplit[1] === 'undefined') {
				return null;
			}

			progress[progressSplit[0]] = progressSplit[1];
		}

		// build progress report object
		return {
			frames: parseInt(progress.frame, 10),
			currentFps: parseInt(progress.fps, 10),
			currentKbps: parseFloat(progress.bitrate.replace('kbits/s', '')),
			targetSize: parseInt(progress.size, 10),
			timemark: progress.time
		};
	}
}

export default FfmpegCommand;