/** * Render * Render a recording file as an animated gif image * * @author Mohammad Fares */ const tmp = require('tmp'); tmp.setGracefulCleanup(); /** * The directory to render the frames into */ var renderDir = tmp.dirSync({ unsafeCleanup: true }).name; /** * 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(); } ); }); } /** * Read and parse a PNG image file * * @param {String} path the absolute path of the image * @return {Promise} resolve with the parsed PNG image */ function loadPNG(path) { return new Promise(function (resolve, reject) { di.fs.readFile(path, function (error, imageData) { if (error) { return reject(error); } new di.PNG().parse(imageData, function (error, data) { if (error) { return reject(error); } resolve(data); }); }); }); } /** * Get the dimensions of the first rendered frame * * @return {Promise} */ function getFrameDimensions() { // The path of the first rendered frame var framePath = di.path.join(renderDir, "0.png"); // Read and parse a PNG image file return loadPNG(framePath).then(function (png) { return { width: png.width, height: png.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; // Track execution time var start = Date.now(); // 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"), renderDir, options.step,], { detached: false } ); render.stdout.on('data', onData); render.stderr.on('data', onError); render.on('close', onClose); // Track progress of rendering through stdout function onData(data) { // Is not a recordIndex (to skip Electron's logs or new lines) if (isNaN(parseInt(data.toString()))) { return; } progressBar.tick(); } // Track rendering errors observed on stderr function onError(error) { // If error is Buffer, print it, otherwise reject if (!!error && error instanceof Buffer) { console.log(di.chalk.yellow(`[render] ${error.toString('utf8').trim()}`)); } else { render.kill(); reject(new Error("Unknown error [" + typeof error + "]: " + error)); } } // React when rendering process finishes function onClose(code) { if (code !== 0) { reject(new Error("Rendering exited with code " + code)); } else { if (progressBar.complete) { console.log(di.chalk.green('[render] Process successfully completed in ' + (Date.now() - start) + 'ms.')); } else { console.log(di.chalk.yellow('[render] Process completion unverified')); } 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; // Track execution time var start = Date.now(); // 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(); 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(renderDir, index + ".png"); // Read and parse the rendered frame loadPNG(framePath) .then(function (png) { progressBar.tick(); // Set the duration (the delay of the next frame) // The % is used to take the delay of the first frame // as the duration of the last frame gif.setDelay(records[(index + 1) % framesCount].delay); // Add frames gif.addFrame(png.data); // Next callback(); }) .catch(function (error) { callback(error); }); }, function (error) { if (error) { return reject(error); } // Write the footer gif.finish(); // Finish console.log(di.chalk.green('[merge] Process successfully completed in ' + (Date.now() - start) + 'ms.')); 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" + Date.now(), "gif" ); // For adjusting (calculating) the frames delays var adjustFramesDelaysOptions = { frameDelay: config.frameDelay, maxIdleTime: config.maxIdleTime, }; // For rendering the frames into PNG 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.commands.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(di.errorHandler); } //////////////////////////////////////////////////// // 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, }); };