/** * Render * Render a recording file as an animated gif image * * @author Mohammad Fares */ /** * Create a progress bar for processing frames * * @param {String} operation a name for the operation * @param {Number} framesCount * @return {ProgressBar} */ function getProgressBar(operation, framesCount) { return new di.ProgressBar(operation + ' ' + di.chalk.magenta('frame :current/:total') + ' :percent [:bar] :etas', { width: 30, total: framesCount }); } /** * Write the recording data into render/data.json * * @param {Object} recordingFile * @return {Promise} */ function writeRecordingData(recordingFile) { return new Promise(function(resolve, reject) { // Write the data into data.json file in the root path of the app di.fs.writeFile(di.path.join(ROOT_PATH, 'render/data.json'), JSON.stringify(recordingFile.json), 'utf8', function(error) { if (error) { return reject(error); } resolve(); }); }); } /** * Get the dimensions of the first rendered frame * * @return {Promise} */ function getFrameDimensions() { return new Promise(function(resolve, reject) { // The path of the first rendered frame var framePath = di.path.join(ROOT_PATH, 'render/frames/0.png'); // Read and parse the image di.fs.createReadStream(framePath).pipe(new di.PNG()).on('parsed', function() { resolve({ width: this.width, height: this.height }); }); }); } /** * Render the frames into PNG images * * @param {Array} records [{delay, content}, ...] * @param {Object} options {step} * @return {Promise} */ function renderFrames(records, options) { return new Promise(function(resolve, reject) { // The number of frames var framesCount = records.length; // Create a progress bar var progressBar = getProgressBar('Rendering', Math.ceil(framesCount / options.step)); // Execute the rendering process var render = di.spawn(di.electron, [di.path.join(ROOT_PATH, 'render/index.js'), options.step], {detached: false}); render.stderr.on('data', function(error) { render.kill(); reject(new Error(di._.trim(error))); }); render.stdout.on('data', function(data) { progressBar.tick(); // Rendering is completed if (progressBar.complete) { resolve(); } }); }); } /** * Merge the rendered frames into an animated GIF image * * @param {Array} records [{delay, content}, ...] * @param {Object} options {quality, repeat, step, outputFile} * @param {Object} frameDimensions {width, height} * @return {Promise} */ function mergeFrames(records, options, frameDimensions) { return new Promise(function(resolve, reject) { // The number of frames var framesCount = records.length; // Used for the step option var stepsCounter = 0; // Create a progress bar var progressBar = getProgressBar('Merging', Math.ceil(framesCount / options.step)); // The gif image var gif = new di.GIFEncoder(frameDimensions.width, frameDimensions.height, { highWaterMark: 5 * 1024 * 1024 }); // Pipe gif.pipe(di.fs.createWriteStream(options.outputFile)); // Quality gif.setQuality(101 - options.quality); // Repeat gif.setRepeat(options.repeat); // Write the headers gif.writeHeader(); // Foreach frame di.async.eachOfSeries(records, function(frame, index, callback) { if (stepsCounter != 0) { stepsCounter = (stepsCounter + 1) % options.step; return callback(); } stepsCounter = (stepsCounter + 1) % options.step; // The path of the rendered frame var framePath = di.path.join(ROOT_PATH, 'render/frames', index + '.png'); // Read and parse the rendered frame di.fs.createReadStream(framePath).pipe(new di.PNG()).on('parsed', function() { progressBar.tick(); // Set delay gif.setDelay(frame.delay); // Add frames gif.addFrame(this.data); // Next callback(); }); }, function(error) { if (error) { return reject(error); } // Write the footer gif.finish(); resolve(); }); }); } /** * Delete the temporary rendered PNG images * * @return {Promise} */ function cleanup() { return new Promise(function(resolve, reject) { di.fs.emptyDir(di.path.join(ROOT_PATH, 'render/frames'), function(error) { if (error) { return reject(error); } resolve(); }); }); } /** * Executed after the command completes its task * * @param {String} outputFile the path of the rendered image */ function done(outputFile) { console.log('\n' + di.chalk.green('Successfully Rendered')); console.log('The animated GIF image is saved into the file:'); console.log(di.chalk.magenta(outputFile)); process.exit(); } /** * The command's main function * * @param {Object} argv */ function command(argv) { // Frames var records = argv.recordingFile.json.records; var config = argv.recordingFile.json.config; // Number of frames in the recording file var framesCount = records.length; // The path of the output file var outputFile = di.utility.resolveFilePath('render' + (new Date()).getTime(), 'gif'); // For adjusting (calculating) the frames delays var adjustFramesDelaysOptions = { frameDelay: config.frameDelay, maxIdleTime: config.maxIdleTime }; // For rendering the frames into PMG images var renderingOptions = { step: argv.step }; // For merging the rendered frames into an animated GIF image var mergingOptions = { quality: config.quality, repeat: config.repeat, step: argv.step, outputFile: outputFile }; // Overwrite the quality of the rendered image if (argv.quality) { mergingOptions.quality = argv.quality; } // Overwrite the outputFile of the rendered image if (argv.output) { outputFile = argv.output; mergingOptions.outputFile = argv.output; } // Tasks di.asyncPromises.waterfall([ // Remove all previously rendered frames cleanup, // Write the recording data into render/data.json di._.partial(writeRecordingData, argv.recordingFile), // Render the frames into PNG images di._.partial(renderFrames, records, renderingOptions), // Adjust frames delays di._.partial(di.play.adjustFramesDelays, records, adjustFramesDelaysOptions), // Get the dimensions of the first rendered frame di._.partial(getFrameDimensions), // Merge the rendered frames into an animated GIF image di._.partial(mergeFrames, records, mergingOptions), // Delete the temporary rendered PNG images cleanup ]).then(function() { done(outputFile); }).catch(function(error) { di.errorHandler(error.message); }); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'render '; /** * Command's description * @type {String} */ module.exports.describe = 'Render a recording file as an animated gif image'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; /** * Builder * * @param {Object} yargs */ module.exports.builder = function(yargs) { // Define the recordingFile argument yargs.positional('recordingFile', { describe: 'The recording file', type: 'string', coerce: di.utility.loadYAML }); // Define the output option yargs.option('o', { alias: 'output', type: 'string', describe: 'A name for the output file', requiresArg: true, coerce: di._.partial(di.utility.resolveFilePath, di._, 'gif') }); // Define the quality option yargs.option('q', { alias: 'quality', type: 'number', describe: 'The quality of the rendered image (1 - 100)', requiresArg: true }); // Define the quality option yargs.option('s', { alias: 'step', type: 'number', describe: 'To reduce the number of rendered frames (step > 1)', requiresArg: true, default: 1 }); };