commit d1153e58ba4cef0aa65e851a39e8e2d971c69e7d Author: Mohammad Fares Date: Mon Jul 23 01:34:35 2018 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7b9e4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Rendering data +render/frames/* +render/data.json + +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Dependency directories +node_modules + +# Optional npm cache directory +.npm diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..1711c11 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,73 @@ +{ + "requireCurlyBraces": [ + "if", + "else", + "for", + "while", + "do", + "try", + "catch" + ], + "requireSpaceAfterKeywords": [ + "if", + "else", + "for", + "while", + "do", + "switch", + "return", + "try", + "catch" + ], + "requireSemicolons": true, + "requireSpacesInForStatement": true, + "requireSpaceBeforeBlockStatements": true, + "requireParenthesesAroundIIFE": true, + "requireSpacesInConditionalExpression": true, + "requireSpacesInAnonymousFunctionExpression": { + "beforeOpeningCurlyBrace": true + }, + "requireSpacesInNamedFunctionExpression": { + "beforeOpeningCurlyBrace": true + }, + "requireBlocksOnNewline": true, + "disallowEmptyBlocks": false, + "disallowSpacesInsideObjectBrackets": true, + "disallowSpacesInsideArrayBrackets": true, + "disallowSpacesInsideParentheses": true, + "requireSpaceAfterComma": true, + "disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-", "~", "!"], + "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], + "requireSpaceBeforeBinaryOperators": [ + "=", "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", + "&=", "|=", "^=", "+", "-", "*", "/", "%", "<<", ">>", ">>>", "&", + "|", "^", "&&", "||", "===", "==", ">=", "<=", "<", ">", "!=", "!==" + ], + "requireSpaceAfterBinaryOperators": true, + "requireCamelCaseOrUpperCaseIdentifiers": { + "ignoreProperties": true + }, + "disallowKeywords": ["with"], + "disallowMultipleLineStrings": true, + "validateLineBreaks": "LF", + "validateIndentation": 2, + "disallowTrailingComma": true, + "requireLineFeedAtFileEnd": true, + "validateQuoteMarks": { + "mark": "'", + "escape": true + }, + "requireCapitalizedComments": true, + "requireSpaceAfterLineComment": { "allExcept": ["//////////////////////////////////////////////////"] }, + "jsDoc": { + "checkAnnotations": true, + "checkRedundantAccess": true, + "checkTypes": "capitalizedNativeCase", + "requireNewlineAfterDescription": true, + "checkParamExistence": true, + "checkParamNames": true, + "requireParamTypes": true, + "checkRedundantParams": true, + "requireReturnTypes": true + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c7b20e --- /dev/null +++ b/README.md @@ -0,0 +1,369 @@ +# Terminalizer + +[![npm](https://img.shields.io/npm/v/terminalizer.svg)](https://www.npmjs.com/package/terminalizer) +[![npm](https://img.shields.io/npm/l/terminalizer.svg)](https://github.com/faressoft/terminalizer/blob/master/LICENSE) +[![Gitter](https://badges.gitter.im/join_chat.svg)](https://gitter.im/terminalizer/Lobby) +[![Unicorn](https://img.shields.io/badge/nyancat-approved-ff69b4.svg)](https://www.youtube.com/watch?v=QH2-TGUlwu4) + +> Record your terminal and generate animated gif images + +

+ +Built to be jusT cOol 👌🦄 ! + +> If you think so, support me by a `start` and a `follow` 😘 + +Built while listening to [Ever Felt Pt.1 - Otis McDonald](https://www.youtube.com/watch?v=-BiXhuRq7fU) 🎵 And [Nyan Cat](https://www.youtube.com/watch?v=QH2-TGUlwu4) 😛 + +# Table of Contents + +* [Features](#features) +* [Installation](#installation) +* [Getting Started](#getting-started) +* [Compression](#compression) +* [Usage](#usage) + * [Config](#config) + * [Record](#record) + * [Play](#play) + * [Render](#render) + * [Share](#share) + * [Generate](#generate) +* [Configurations](#configurations) + * [Recording](#recording) + * [Delays](#delays) + * [GIF](#gif) + * [Terminal](#terminal) + * [Theme](#theme) + * [Watermark](#watermark) + * [Frame Box](#frame-box) + * [Null Frame](#null-frame) + * [Window Frame](#window-frame) + * [Floating Frame](#floating-frame) + * [Solid Frame](#solid-frame) + * [Solid Frame Without Title](#solid-frame-without-title) + * [Styling Hint](#styling-hint) +* [License](#license) + +## Features + +* Highly customizable. +* Corss platform (Linux, Windows, MacOS). +* Custom `window frames`. +* Custom `font`. +* Custom `colors`. +* Custom `styles` with `CSS`. +* Watermark. +* Edit before rendering. +* Skipping frames by a step value to reduce the number of rendered frames. +* Render images with texts on them instead of capturing your screen for better quality. +* The ability to configure: + * The command to capture (bash, powershell.exe, yourOwnCommand, etc) + * The current working directory. + * Explicit values for the number of cols and rows. + * GIF quality and repeating. + * Frames delays. + * The max idle time between frames. + * cursor style. + * font. + * font size. + * line height. + * letter spacing. + * theme. + +## Installation + +You need first to install [Node.js](https://nodejs.org/en/download/), then install the tool globally using this command: + +```bash +npm install -g terminalizer +``` + +

+ +## Getting Started + +Start recording your terminal using the command `record`. + +```bash +terminalizer record demo +``` + +A file called `demo.yml` will be created in the current directory. You can open it using any editor to edit the configurations and the recoreded frames. You can replay your recording using the command `play`. + +```bash +terminalizer record demo +``` + +Now let's render our recording as an animated gif. + +```bash +terminalizer render demo +``` + +### Compression + +GIF compression is not implementated yet. For now we recommend [https://gifcompressor.com](https://gifcompressor.com). + +## Usage + +> You can use the option `--help` to get more details about the commands and their options. + +```bash +terminalizer [options] +``` + +### Config + +> Generate a config file in the current directory + +```bash +terminalizer config +``` + +### Record + +> Record your terminal and create a recording file + +```bash +terminalizer record +``` + +Options + +``` +-c, --config Overwrite the default configurations [string] +-d, --command The command to be executed [string] [default: null] +``` + +Examples + +``` +terminalizer record foo Start recording and create a recording file called foo.yml +terminalizer record foo --config config.yml Start recording with with your own configurations +``` + +### Play + +> Play a recording file on your terminal + +```bash +terminalizer play +``` + +Options + +``` +-r, --real-timing Use the actual delays between frames as recorded [boolean] [default: false] +-s, --speed-factor Speed factor, multiply the frames delays by this factor [number] [default: 1] +``` + +### Render + +> Render a recording file as an animated gif image + +```bash +terminalizer render +``` + +Options + +``` +-o, --output A name for the output file [string] +-q, --quality The quality of the rendered image (1 - 100) [number] +-s, --step To reduce the number of rendered frames (step > 1) [number] [default: 1] +``` + +### Share + +> Upload a recording file and get a link for an online player + +```bash +terminalizer share +``` + +### Generate + +> Generate a web player for a recording file + +```bash +terminalizer generate +``` + +## Configurations + +The default `config.yml` file is stored at root directory of the project. Execute the bellow command to copy it to your current directory. + +> Use any editor to edit the copied `config.yml`, then use the option `-c` to overwrite the default one. + +```bash +terminalizer config +``` + +## Recording + +* `command`: Specify a command to be executed like `/bin/bash -l`, `ls`, or any other commands. The default is `bash` for `Linux` or `powershell.exe` for `Windows`. +* `cwd`: Specify the current working directory path. The default is the current working directory path. +* `env`: Export additional ENV variables, to be read by your scripts when start recording. +* `cols`: Explicitly set the number of columns or use `auto` to take the current number of columns of your shell. +* `rows`: Explicitly set the number of rows or use `auto` to take the current number of columns of your shell. + +## Delays + +* `frameDelay`: The delay between frames in ms. If the value is `auto` use the actual recording delays. +* `maxIdleTime`: Maximum delay between frames in ms. Ignored if the `frameDelay` isn't set to `auto`. Set to `auto` to prevnt limiting the max idle time. + +## GIF + +* `quality`: The quality of the generated GIF image (1 - 100). +* `repeat`: Amount of times to repeat GIF: + * If value is `-1`, play once. + * If value is `0`, loop indefinitely. + * If value is `a` positive number, loop n times. + +## Terminal + +* `cursorStyle`: Cursor style can be one of `block`, `underline`, or `bar`. +* `fontFamily`: You can use any font that is installed on your machine like `Monaco` or `Lucida Console`. +* `fontSize`: The size of the font in pixels. +* `lineHeight`: The height of lines in pixels. +* `letterSpacing`: The spacing between letters in pixels. + +## Theme + +You can set the colors of your terminal using one of the CSS formats: + +* Hex: `#FFFFFF`. +* RGB: `rgb(255, 255, 255)`. +* HSL: `hsl(0, 0%, 100%)`. +* Name: 'white', 'red', 'blue', + +> You can use the the value `transparent` too. + +The default colors that are assigned to the termianl colors are: + +* background: transparent +* foreground: #afafaf. +* cursor: #c7c7c7. +* black: #232628. +* red: #fc4384. +* green: #b3e33b. +* yellow: #ffa727. +* blue: #75dff2. +* magenta: #ae89fe. +* cyan: #708387. +* white: #d5d5d0. +* brightBlack: #626566. +* brightRed: #ff7fac. +* brightGreen: #c8ed71. +* brightYellow: #ebdf86. +* brightBlue: #75dff2. +* brightMagenta: #ae89fe. +* brightCyan: #b1c6ca. +* brightWhite: #f9f9f4. + +## Watermark + +You can add a watermark logo to your generated GIF images. + +

+ +``` +watermark: + imagePath: AbsolutePathOrURL + style: + position: absolute + right: 15px + bottom: 15px + width: 100px + opacity: 0.9 +``` + +* `watermark.imagePath`: An absolute path for the image on your machine or a url. +* `watermark.style`: Apply CSS styles (camelCase) to the watermark image, like resizing it. + +## Frame Box + +Terminalizer comes with predefined frames that you can use to make your GIF images look cool. + +* `frameBox.type`: Can be `null`, `window`, `floating`, or `solid`. +* `frameBox.title`: To display a title for the frame or `null`. +* `frameBox.style`: To apply custom CSS styles or to overwrite the current onces. + +### Null Frame + +No frame, just your recording. + +

+ +> Don't forget to add a `backgroundColor` under `style`. + +``` +frameBox: + type: null + title: null + style: + backgroundColor: black +``` + +### Window Frame + +

+ +``` +frameBox: + type: window + title: Terminalizer + style: [] +``` + +### Floating Frame + +

+ +``` +frameBox: + type: floating + title: Terminalizer + style: [] +``` + +### Solid Frame + +

+ +``` +frameBox: + type: solid + title: Terminalizer + style: [] +``` + +### Solid Frame Without Title + +

+ +``` +frameBox: + type: solid + title: null + style: [] +``` + +### Styling Hint + +You can disable the default shadows and margins by: + +

+ +``` +frameBox: + type: solid + title: null + style: + boxShadow: none + margin: 0px +``` + +# License + +This project is under the MIT license. diff --git a/app.js b/app.js new file mode 100644 index 0000000..cf194aa --- /dev/null +++ b/app.js @@ -0,0 +1,96 @@ +/** + * Terminalizer + * + * @author Mohammad Fares + */ + +var yargs = require('yargs'), + is = require('is_js'), + chalk = require('chalk'), + _ = require('lodash'), + async = require('async'), + asyncPromises = require('async-promises'), + death = require('death'), + stringArgv = require('string-argv'), + path = require('path'), + ProgressBar = require('progress'), + GIFEncoder = require('gif-encoder'), + PNG = require('pngjs').PNG, + yaml = require('js-yaml'), + os = require('os'), + spawn = require('child_process').spawn, + electron = require('electron'), + deepmerge = require('deepmerge'), + pty = require('node-pty-prebuilt'), + fs = require('fs-extra'), + now = require('performance-now'); +var package = require('./package.json'), + utility = require('./utility.js'), + di = require('./di.js'), + play = require('./commands/play.js'); + +// Define the DI as a global object +global.di = di; + +// Define the the root path of the app as a global constant +global.ROOT_PATH = __dirname; + +// Dependency Injection +di.set('is', is); +di.set('chalk', chalk); +di.set('_', _); +di.set('async', async); +di.set('asyncPromises', asyncPromises); +di.set('death', death); +di.set('stringArgv', stringArgv); +di.set('path', path); +di.set('ProgressBar', ProgressBar); +di.set('GIFEncoder', GIFEncoder); +di.set('PNG', PNG); +di.set('os', os); +di.set('spawn', spawn); +di.set('electron', electron); +di.set('deepmerge', deepmerge); +di.set('pty', pty); +di.set('fs', fs); +di.set('now', now); +di.set('fs', fs); +di.set('yaml', yaml); +di.set('utility', utility); +di.set('play', play); +di.set('errorHandler', errorHandler); + +// Initialize yargs +yargs.usage('Usage: $0 [options]') + // Add link + .epilogue('For more information, check https://www.terminalizer.com') + // Set the version number + .version(package.version) + // Add aliases for version and help options + .alias({v: 'version', h: 'help'}) + // Require to pass a command + .demandCommand(1, 'The command is missing') + // Strict mode + .strict() + // Set width to 90 cols + .wrap(100) + // Automatically loads the commands + .commandDir('commands') + // Handle failures + .fail(errorHandler); + +// Parse the command line arguments +var argv = yargs.parse(); + +/** + * Print exceptions + * + * @param {String} message + */ +function errorHandler(message) { + + console.error('Error: \n ' + message + '\n'); + console.error('Hint:\n Use the option ' + chalk.green('--help') + ' to get help about the usage'); + process.exit(1); + +} diff --git a/bin/app.js b/bin/app.js new file mode 100644 index 0000000..838f841 --- /dev/null +++ b/bin/app.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +/** + * Terminalizer + * + * @author Mohammad Fares + */ + +require('../app.js'); diff --git a/commands/config.js b/commands/config.js new file mode 100644 index 0000000..cab5e5a --- /dev/null +++ b/commands/config.js @@ -0,0 +1,53 @@ +/** + * Config + * Generate a config file in the current directory + * + * @author Mohammad Fares + */ + +/** + * Executed after the command completes its task + */ +function done() { + + console.log(di.chalk.green('Successfully Saved')); + console.log('The config file is saved into the file:'); + console.log(di.chalk.magenta('config.yml')); + + // Terminate the app + process.exit(); + +} + +/** + * The command's main function + * + * @param {Object} argv + */ +function command(argv) { + + di.fs.copy(di.path.join(ROOT_PATH, 'config.yml'), 'config.yml', done); + +} + +//////////////////////////////////////////////////// +// Command Definition ////////////////////////////// +//////////////////////////////////////////////////// + +/** + * Command's usage + * @type {String} + */ +module.exports.command = 'config'; + +/** + * Command's description + * @type {String} + */ +module.exports.describe = 'Generate a config file in the current directory'; + +/** + * Command's handler function + * @type {Function} + */ +module.exports.handler = command; diff --git a/commands/generate.js b/commands/generate.js new file mode 100644 index 0000000..d09b759 --- /dev/null +++ b/commands/generate.js @@ -0,0 +1,65 @@ +/** + * Generate + * Generate a web player for a recording file + * + * @author Mohammad Fares + */ + +/** + * Executed after the command completes its task + */ +function done() { + + // Terminate the app + process.exit(); + +} + +/** + * The command's main function + * + * @param {Object} argv + */ +function command(argv) { + + console.log('This command is not implemented yet. It will be avalible in the next versions'); + +} + +//////////////////////////////////////////////////// +// Command Definition ////////////////////////////// +//////////////////////////////////////////////////// + +/** + * Command's usage + * @type {String} + */ +module.exports.command = 'generate '; + +/** + * Command's description + * @type {String} + */ +module.exports.describe = 'Generate a web player for a recording file'; + +/** + * 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 + }); + +}; diff --git a/commands/play.js b/commands/play.js new file mode 100644 index 0000000..bc3325c --- /dev/null +++ b/commands/play.js @@ -0,0 +1,231 @@ +/** + * Play + * Play a recording file on your terminal + * + * @author Mohammad Fares + */ + +/** + * Print the passed content + * + * @param {String} content + * @param {Function} callback + */ +function playCallback(content, callback) { + + process.stdout.write(content); + callback(); + +} + +/** + * Executed after the command completes its task + */ +function done() { + + // Full reset for the terminal + process.stdout.write('\033c'); + process.exit(); + +} + +/** + * The command's main function + * + * @param {Object} argv + */ +function command(argv) { + + process.stdin.pause(); + + // Playing optinos + var options = { + frameDelay: argv.recordingFile.json.config.frameDelay, + maxIdleTime: argv.recordingFile.json.config.maxIdleTime + }; + + // Use the actual delays between frames as recorded + if (argv.realTiming) { + + options = { + frameDelay: 'auto', + maxIdleTime: 'auto' + }; + + } + + // When app is closing + di.death(done); + + // Add the speedFactor option + options.speedFactor = argv.speedFactor; + + // Adjust frames delays + adjustFramesDelays(argv.recordingFile.json.records, options); + + // Play the recording records + play(argv.recordingFile.json.records, playCallback, null, options); + +} + +/** + * Adjust frames delays + * + * Options: + * + * - frameDelay (default: auto) + * - Delay between frames in ms + * - If the value is `auto` use the actual recording delays + * + * - maxIdleTime (default: 2000) + * - Maximum delay between frames in ms + * - Ignored if the `frameDelay` isn't set to `auto` + * - Set to `auto` to prevnt limiting the max idle time + * + * - speedFactor (default: 1) + * - Multiply the frames delays by this factor + * + * @param {Array} records + * @param {Object} options (optional) + */ +function adjustFramesDelays(records, options) { + + // Default value for options + if (typeof options === 'undefined') { + options = {}; + } + + // Default value for options.frameDelay + if (typeof options.frameDelay === 'undefined') { + options.frameDelay = 'auto'; + } + + // Default value for options.maxIdleTime + if (typeof options.maxIdleTime === 'undefined') { + options.maxIdleTime = 2000; + } + + // Default value for options.speedFactor + if (typeof options.speedFactor === 'undefined') { + options.speedFactor = 1; + } + + // Foreach record + records.forEach(function(record) { + + // Adjust the delay according to the options + if (options.frameDelay != 'auto') { + record.delay = options.frameDelay; + } else if (options.maxIdleTime != 'auto' && record.delay > options.maxIdleTime) { + record.delay = options.maxIdleTime; + } + + // Apply speedFactor + record.delay = record.delay * options.speedFactor; + + }); + +} + +/** + * Play recording records + * + * @param {Array} records + * @param {Function} playCallback + * @param {Function|Null} doneCallback + */ +function play(records, playCallback, doneCallback) { + + var tasks = []; + + // Default value for doneCallback + if (typeof doneCallback === 'undefined') { + doneCallback = null; + } + + // Foreach record + records.forEach(function(record) { + + tasks.push(function(callback) { + + setTimeout(function() { + playCallback(record.content, callback); + }, record.delay); + + }); + + }); + + di.async.series(tasks, function(error, results) { + + if (doneCallback) { + doneCallback(); + } + + }); + +} + +//////////////////////////////////////////////////// +// Command Definition ////////////////////////////// +//////////////////////////////////////////////////// + +/** + * Command's usage + * @type {String} + */ +module.exports.command = 'play '; + +/** + * Command's description + * @type {String} + */ +module.exports.describe = 'Play a recording file on your terminal'; + +/** + * 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 real-timing option + yargs.option('r', { + alias: 'real-timing', + describe: 'Use the actual delays between frames as recorded', + type: 'boolean', + default: false + }); + + // Define the speed-factor option + yargs.option('s', { + alias: 'speed-factor', + describe: 'Speed factor, multiply the frames delays by this factor', + type: 'number', + default: 1.0 + }); + +}; + +//////////////////////////////////////////////////// +// Module ////////////////////////////////////////// +//////////////////////////////////////////////////// + +// Play recording records +module.exports.play = play; + +// Adjust frames delays +module.exports.adjustFramesDelays = adjustFramesDelays; diff --git a/commands/record.js b/commands/record.js new file mode 100644 index 0000000..57ed714 --- /dev/null +++ b/commands/record.js @@ -0,0 +1,273 @@ +/** + * Record + * Record your terminal and create a recording file + * + * @author Mohammad Fares + */ + +/** + * The path of the recording file + * @type {String} + */ +var recordingFile = null; + +/** + * The normalized configurations + * @type {Object} {json, raw} + */ +var config = {}; + +/** + * To keep tracking of the timestamp + * of the last inserted record + * @type {Number} + */ +var lastRecordTimestamp = null; + +/** + * To store the records + * @type {Array} + */ +var records = []; + +/** + * Normalize the config file + * + * - Set default values in the json and raw + * - Change the formatting of the values in the json and raw + * + * @param {Object} config {json, raw} + * @return {Object} {json, raw} + */ +function normalizeConfig(config) { + + // Default value for command + if (!config.json.command) { + + // Windows OS + if (di.os.platform() === 'win32') { + di.utility.changeYAMLValue(config, 'command', 'powershell.exe'); + } else { + di.utility.changeYAMLValue(config, 'command', 'bash'); + } + + } + + // Default value for cwd + if (!config.json.cwd) { + di.utility.changeYAMLValue(config, 'cwd', process.cwd()); + } else { + di.utility.changeYAMLValue(config, 'cwd', di.path.resolve(config.json.cwd)); + } + + // Default value for cols + if (di.is.not.number(config.json.cols)) { + di.utility.changeYAMLValue(config, 'cols', process.stdout.columns); + } + + // Default value for rows + if (di.is.not.number(config.json.rows)) { + di.utility.changeYAMLValue(config, 'rows', process.stdout.rows); + } + + return config; + +} + +/** + * Calculate the duration from the last inserted record in ms, + * and update lastRecordTimestamp + * + * @return {Number} + */ +function getDuration() { + + // Calculate the duration from the last inserted record + var duration = di.now().toFixed() - lastRecordTimestamp; + + // Update the lastRecordTimestamp + lastRecordTimestamp = di.now().toFixed(); + + return duration; + +} + +/** + * When an input or output is received from the PTY instance + * + * @param {Buffer} content + */ +function onData(content) { + + process.stdout.write(content); + + var duration = getDuration(); + + if (duration < 5) { + var lastRecord = records[records.length - 1]; + lastRecord.content += content; + return; + } + + records.push({ + delay: duration, + content: content + }); + +} + +/** + * Executed after the command completes its task + * Store the output file with reserving the comments + */ +function done() { + + var outputYAML = ''; + + // Add config parent element + outputYAML += '# The configurations that used for the recording, feel free to edit them\n'; + outputYAML += 'config:\n\n'; + + // Add the configurations with indentation + outputYAML += config.raw.replace(/^/gm, ' '); + + // Add the records + outputYAML += '\n# Records, feel free to edit them\n'; + outputYAML += di.yaml.dump({records: records}); + + // Store the data into the recording file + try { + di.fs.writeFileSync(recordingFile, outputYAML, 'utf8'); + } catch (error) { + di.errorHandler(error.message); + process.exit(); + } + + console.log(di.chalk.green('Successfully Recorded')); + console.log('The recording data is saved into the file:'); + console.log(di.chalk.magenta(recordingFile)); + console.log('You can edit the file and even change the configurations.'); + + // Terminate the app + process.exit(); + +} + +/** + * The command's main function + * + * @param {Object} argv + */ +function command(argv) { + + // Normalize the configurations + config = normalizeConfig(argv.config); + + // Store the path of the recordingFile + recordingFile = argv.recordingFile; + + // Overwrite the command to be executed + if (argv.command) { + di.utility.changeYAMLValue(config, 'command', argv.command); + } + + // Split the command and its arguments + var args = di.stringArgv(config.json.command); + var command = args[0]; + var commandArguments = args.slice(1); + + // PTY instance + var ptyProcess = di.pty.spawn(command, commandArguments, { + name: 'xterm-color', + cols: config.json.cols, + rows: config.json.rows, + cwd: config.json.pwd, + env: di.deepmerge(process.env, config.json.env) + }); + + // Input and output capturing and redirection + ptyProcess.on('data', onData); + ptyProcess.on('exit', done); + process.stdin.on('data', ptyProcess.write.bind(ptyProcess)); + + // Input and output normalization + process.stdin.setEncoding('utf8'); + process.stdout.setDefaultEncoding('utf8'); + process.stdin.setRawMode(true); + process.stdin.resume(); + +} + +//////////////////////////////////////////////////// +// Command Definition ////////////////////////////// +//////////////////////////////////////////////////// + +/** + * Command's usage + * @type {String} + */ +module.exports.command = 'record '; + +/** + * Command's description + * @type {String} + */ +module.exports.describe = 'Record your terminal and create a recording file'; + +/** + * Handler + * + * @param {Object} argv + */ +module.exports.handler = function(argv) { + + // The default configurations + var defaultConfig = di.utility.getDefaultConfig(); + + // Default value for the config option + if (typeof argv.config == 'undefined') { + argv.config = di.utility.getDefaultConfig(); + } + + // Execute the command + command(argv); + +}; + +/** + * Builder + * + * @param {Object} yargs + */ +module.exports.builder = function(yargs) { + + // Define the recordingFile argument + yargs.positional('recordingFile', { + describe: 'A name for the recording file', + type: 'string', + coerce: di._.partial(di.utility.resolveFilePath, di._, 'yml') + }); + + // Define the config option + yargs.option('c', { + alias: 'config', + type: 'string', + describe: 'Overwrite the default configurations', + requiresArg: true, + coerce: di.utility.loadYAML + }); + + // Define the config option + yargs.option('d', { + alias: 'command', + type: 'string', + describe: 'The command to be executed', + requiresArg: true, + default: null + }); + + // Add examples + yargs.example('$0 record foo', 'Start recording and create a recording file called foo.yml'); + yargs.example('$0 record foo --config config.yml', 'Start recording with with your own configurations'); + +}; diff --git a/commands/render.js b/commands/render.js new file mode 100644 index 0000000..8856a4e --- /dev/null +++ b/commands/render.js @@ -0,0 +1,381 @@ +/** + * 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 + }); + +}; diff --git a/commands/share.js b/commands/share.js new file mode 100644 index 0000000..b87ea7f --- /dev/null +++ b/commands/share.js @@ -0,0 +1,65 @@ +/** + * Share + * Upload a recording file and get a link for an online player + * + * @author Mohammad Fares + */ + +/** + * Executed after the command completes its task + */ +function done() { + + // Terminate the app + process.exit(); + +} + +/** + * The command's main function + * + * @param {Object} argv + */ +function command(argv) { + + console.log('This command is not implemented yet. It will be avalible in the next versions'); + +} + +//////////////////////////////////////////////////// +// Command Definition ////////////////////////////// +//////////////////////////////////////////////////// + +/** + * Command's usage + * @type {String} + */ +module.exports.command = 'share '; + +/** + * Command's description + * @type {String} + */ +module.exports.describe = 'Upload a recording file and get a link for an online player'; + +/** + * 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 + }); + +}; diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..28cca2d --- /dev/null +++ b/config.yml @@ -0,0 +1,106 @@ +# Specify a command to be executed +# like `/bin/bash -l`, `ls`, or any other commands +# the default is bash for Linux +# or powershell.exe for Windows +command: null + +# Specify the current working directory path +# the default is the current working directory path +cwd: null + +# Export additional ENV variables +env: + recording: true + +# Explicitly set the number of columns +# or use `auto` to take the current +# number of columns of your shell +cols: auto + +# Explicitly set the number of rows +# or use `auto` to take the current +# number of rows of your shell +rows: auto + +# Amount of times to repeat GIF +# If value is -1, play once +# If value is 0, loop indefinitely +# If value is a positive number, loop n times +repeat: 0 + +# Quality +# 1 - 100 +quality: 100 + +# Delay between frames in ms +# If the value is `auto` use the actual recording delays +frameDelay: auto + +# Maximum delay between frames in ms +# Ignored if the `frameDelay` isn't set to `auto` +# Set to `auto` to prevnt limiting the max idle time +maxIdleTime: 2000 + +# The surrounding frame box +# The `type` can be null, window, floating, or solid` +# To hide the title use the value null +# Don't forget to add a backgroundColor style with a null as type +frameBox: + type: floating + title: Terminalizer + style: + border: 0px black solid + # boxShadow: none + # margin: 0px + +# Add a watermark image to the rendered gif +# You need to specify an absolute path for +# the image on your machine or a url, and you can also +# add your own CSS styles +watermark: + imagePath: null + style: + position: absolute + right: 15px + bottom: 15px + width: 100px + opacity: 0.9 + +# Cursor style can be one of +# `block`, `underline`, or `bar` +cursorStyle: block + +# Font family +# You can use any font that is installed on your machine +fontFamily: Monaco, Lucida Console + +# The size of the font +fontSize: 12 + +# The height of lines +lineHeight: 1 + +# The spacing between letters +letterSpacing: 0 + +# Theme +theme: + background: "transparent" + foreground: "#afafaf" + cursor: "#c7c7c7" + black: "#232628" + red: "#fc4384" + green: "#b3e33b" + yellow: "#ffa727" + blue: "#75dff2" + magenta: "#ae89fe" + cyan: "#708387" + white: "#d5d5d0" + brightBlack: "#626566" + brightRed: "#ff7fac" + brightGreen: "#c8ed71" + brightYellow: "#ebdf86" + brightBlue: "#75dff2" + brightMagenta: "#ae89fe" + brightCyan: "#b1c6ca" + brightWhite: "#f9f9f4" diff --git a/di.js b/di.js new file mode 100644 index 0000000..c740051 --- /dev/null +++ b/di.js @@ -0,0 +1,43 @@ +/** + * Dependency Injection + * + * @author Mohammad Fares + */ + +var is = require('is_js'); + +/** + * Dependencies + * @type {Object} + */ +var dependency = {}; + +/** + * Get a specific dependency + * + * @param {String} key + * @return {Object|Null} return null if not found + */ +module.exports.get = function(key) { + + // Not found + if (is.not.propertyDefined(dependency, key)) { + return null; + } + + return dependency[key]; + +}; + +/** + * Set/Add a dependency + * + * @param {String} key + * @param {Object} value + */ +module.exports.set = function(key, value) { + + dependency[key] = value; + module.exports[key] = value; + +}; diff --git a/img/demo.gif b/img/demo.gif new file mode 100644 index 0000000..b81eadd Binary files /dev/null and b/img/demo.gif differ diff --git a/img/frames/floating.gif b/img/frames/floating.gif new file mode 100644 index 0000000..16ac2bf Binary files /dev/null and b/img/frames/floating.gif differ diff --git a/img/frames/null.gif b/img/frames/null.gif new file mode 100644 index 0000000..5e27cf4 Binary files /dev/null and b/img/frames/null.gif differ diff --git a/img/frames/solid.gif b/img/frames/solid.gif new file mode 100644 index 0000000..32682e7 Binary files /dev/null and b/img/frames/solid.gif differ diff --git a/img/frames/solid_without_title.gif b/img/frames/solid_without_title.gif new file mode 100644 index 0000000..2acf9f0 Binary files /dev/null and b/img/frames/solid_without_title.gif differ diff --git a/img/frames/solid_without_title_without_shadows.gif b/img/frames/solid_without_title_without_shadows.gif new file mode 100644 index 0000000..7d24510 Binary files /dev/null and b/img/frames/solid_without_title_without_shadows.gif differ diff --git a/img/frames/window.gif b/img/frames/window.gif new file mode 100644 index 0000000..7979fca Binary files /dev/null and b/img/frames/window.gif differ diff --git a/img/install.gif b/img/install.gif new file mode 100644 index 0000000..9b7b74c Binary files /dev/null and b/img/install.gif differ diff --git a/img/watermark.gif b/img/watermark.gif new file mode 100644 index 0000000..ac0abb8 Binary files /dev/null and b/img/watermark.gif differ diff --git a/lib/terminalizer.css b/lib/terminalizer.css new file mode 100644 index 0000000..7998feb --- /dev/null +++ b/lib/terminalizer.css @@ -0,0 +1,222 @@ +/** + * Terminalizer Web Plugin + * https://terminalizer.com + * + * @author Mohammad Fares + */ + +.terminalizer { + display: inline-block; +} + +.terminalizer .xterm-viewport { + overflow-y: hidden; +} + +.terminalizer-frame { + position: relative; +} + +/*//// [ Reset ] ////*/ + +.terminalizer div { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-weight: inherit; + font-style: inherit; + font-size: 100%; + font-family: inherit; + vertical-align: baseline; +} + +/*//// [ Window ] ////*/ + +.terminalizer-frame.terminalizer-window { + -moz-border-radius: 6px; + -webkit-border-radius: 6px; + border-radius: 6px; + border: 1px solid #B3B3B3; + box-shadow: 0px 0px 18px #B3B3B3; + margin: 18px; + overflow: hidden; +} + +.terminalizer-frame.terminalizer-window .terminalizer-titlebar { + -moz-border-top-left-radius: 6px; + -moz-border-top-right-radius: 6px; + -webkit-border-top-left-radius: 6px; + -webkit-border-top-right-radius: 6px; + background: #E8E8E8; + background: -moz-linear-gradient(top, #EBEBEB, #D6D6D6); + background: -ms-linear-gradient(top, #EBEBEB, #D6D6D6); + background: -o-linear-gradient(top, #EBEBEB, #D6D6D6); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0.0, #EBEBEB, color-stop(1.0, #D6D6D6))); + background: -webkit-linear-gradient(top, #EBEBEB, #D6D6D6); + background: linear-gradient(top, #EBEBEB, #D6D6D6); + border-bottom: 1px solid #B1AEB1; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + border-top: 1px solid #F3F1F3; + color: #3B4247; + font-family: Arial, sans-serif; + font-size: 14px; + height: 22px; + line-height: 22px; + position: relative; + text-align: center; + width: 100%; +} + +.terminalizer-frame.terminalizer-window .terminalizer-titlebar .buttons { + left: 8px; + line-height: 0px; + position: absolute; + top: 3.5px; +} + +.terminalizer-frame.terminalizer-window .terminalizer-titlebar .close { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #FF5C5C; + border-radius: 50%; + border: 1px solid #E33E41; + display: inline-block; + height: 12px; + width: 12px; +} + +.terminalizer-frame.terminalizer-window .terminalizer-titlebar .minimize { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #FFBD4C; + border-radius: 50%; + border: 1px solid #E09E3E; + display: inline-block; + height: 12px; + margin-left: 4px; + width: 12px; +} + +.terminalizer-frame.terminalizer-window .terminalizer-titlebar .maximize { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #00CA56; + border-radius: 50%; + border: 1px solid #14AE46; + display: inline-block; + height: 12px; + margin-left: 4px; + width: 12px; +} + +.terminalizer-frame.terminalizer-window .terminalizer-body { + background-color: #1D1D1D; + padding: 10px; +} + +/*//// [ Floating ] ////*/ + +.terminalizer-frame.terminalizer-floating { + -moz-border-radius: 6px; + -webkit-border-radius: 6px; + background-color: #1D1D1D; + border-radius: 6px; + box-shadow: 0px 0px 18px #B3B3B3; + margin: 18px; + overflow: hidden; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-titlebar { + color: white; + font-family: Arial, sans-serif; + font-size: 14px; + height: 34px; + line-height: 34px; + position: relative; + text-align: center; + width: 100%; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-titlebar .buttons { + left: 13px; + line-height: 0px; + position: absolute; + top: 9px; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-titlebar .close { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #FF5C5C; + border-radius: 50%; + display: inline-block; + height: 15px; + width: 15px; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-titlebar .minimize { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #FFBD4C; + border-radius: 50%; + display: inline-block; + height: 15px; + line-height: 10px; + margin-left: 4px; + width: 15px; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-titlebar .maximize { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + background: #00CA56; + border-radius: 50%; + display: inline-block; + height: 15px; + line-height: 10px; + margin-left: 4px; + width: 15px; +} + +.terminalizer-frame.terminalizer-floating .terminalizer-body { + padding: 20px; +} + +/*//// [ Solid ] ////*/ + +.terminalizer-frame.terminalizer-solid { + -moz-border-radius: 6px; + -webkit-border-radius: 6px; + background-color: #1D1D1D; + border-radius: 6px; + box-shadow: 0px 0px 18px #B3B3B3; + margin: 18px; + overflow: hidden; +} + +.terminalizer-frame.terminalizer-solid .terminalizer-titlebar { + color: white; + font-family: Arial, sans-serif; + font-size: 14px; + position: relative; + text-align: center; + width: 100%; +} + +.terminalizer-frame.terminalizer-solid .terminalizer-titlebar .title { + margin: 15px 15px 15px; +} + +.terminalizer-frame.terminalizer-solid .terminalizer-titlebar .title:empty { + display: none; +} + +.terminalizer-frame.terminalizer-solid .terminalizer-titlebar .buttons { + display: none; +} + +.terminalizer-frame.terminalizer-solid .terminalizer-body { + padding: 20px; +} diff --git a/lib/terminalizer.js b/lib/terminalizer.js new file mode 100644 index 0000000..665c12d --- /dev/null +++ b/lib/terminalizer.js @@ -0,0 +1,300 @@ +/** + * Terminalizer Web Plugin + * https://terminalizer.com + * + * @author Mohammad Fares + */ + +(function($) { + + $.fn.terminalizer = function(options) { + + /** + * The target object + * @type {Object} + */ + var self = this; + + /** + * The terminal instance + * @type {Object} + */ + var term = null; + + /** + * Recording file + * @type {Object} + */ + var data = null; + + /** + * HTML template code + * @type {String} + */ + var template = '
' + + '
' + + '
' + + '
' + + '
'; + + // Default options + options = $.extend({ + recordingFile: null, + realTiming: false, + speedFactor: 1.0, + beforeMiddleware: null, + afterMiddleware: null + }, options); + + // Load the recording file + loadJSON(options.recordingFile).then(function(result) { + + // Cache the data + data = result; + + // Marge the plugin's options with recording file's configs + options = $.extend(data.config, options); + + // Terminal + term = new Terminal({ + cols: options.cols, + rows: options.rows, + cursorStyle: options.cursorStyle, + fontFamily: options.fontFamily, + fontSize: options.fontSize, + lineHeight: options.lineHeight, + letterSpacing: options.letterSpacing, + allowTransparency: true, + theme: options.theme + }); + + // Insert the html template + self.html($(template)); + + if (options.frameBox.type) { + self.find('.terminalizer-frame').addClass('terminalizer-' + options.frameBox.type); + } + + if (options.frameBox.type && options.frameBox.title) { + self.find('.terminalizer-frame .title').text(options.frameBox.title); + } + + self.find('.terminalizer-frame').css(options.frameBox.style); + + // Open the terminal + term.open(self.find('.terminalizer-body')[0]); + term.focus(); + + // Add a watermark + if (options.watermark.imagePath) { + return self._addWatermark(options.watermark); + } + + }).then(function() { + + // Play + play(data.records, self._playCallback, self._doneCallback, options); + + }).catch(function(error) { + + console.error(error); + + }); + + /** + * Add a watermark and wait until it is fully loaded + * + * @param {Object} watermarkConfig {imagePath, style} + * @return {Promise} + */ + this._addWatermark = function(watermarkConfig) { + + var watermarkImg = document.createElement('img'); + + $(watermarkImg).addClass('terminalizer-watermark'); + $(watermarkImg).attr('src', watermarkConfig.imagePath); + $(watermarkImg).css(watermarkConfig.style); + + this.find('.terminalizer-frame').prepend(watermarkImg); + + return new Promise(function(resolve, reject) { + + $('.terminalizer-watermark').on('load', resolve); + + }); + + }; + + /** + * Executed after all frames are played + */ + this._doneCallback = function() { + + self.trigger('playingDone'); + + }; + + /** + * Print the passed record's content + * + * @param {String} record + * @param {Function} callback + */ + this._playCallback = function(record, callback) { + + var tasks = []; + + // The beforeMiddleware is set + if (options.beforeMiddleware) { + + tasks.push(function(callback) { + options.beforeMiddleware.call(self, record, callback.bind(null, null, null)); + }); + + } + + // Rendering + tasks.push(function(callback) { + + term.write(record.content); + + // Wrokaround since xterm doesn't provide a rendered event + var renderCheckTimer = setInterval(function() { + + if (term.writeInProgress) { + return; + } + + clearInterval(renderCheckTimer); + callback(); + + + }, 1); + + }); + + // The afterMiddleware is set + if (options.afterMiddleware) { + + tasks.push(function(callback) { + options.afterMiddleware.call(self, record, callback.bind(null, null, null)); + }); + + } + + async.series(tasks, function(error, result) { + + callback(); + + }); + + }; + + return this; + + }; + + /** + * Load, and parse JSON files + * + * @param {String} url + * @return {Promise} + */ + function loadJSON(url) { + + return new Promise(function(resolve, reject) { + + $.getJSON(url).done(resolve).fail(function(jqxhr, textStatus, error) { + reject('Failed to load ' + url); + }); + + }); + + } + + /** + * Play recording records + * + * Options: + * + * - frameDelay (default: auto) + * - Delay between frames in ms + * - If the value is `auto` use the actual recording delays + * + * - maxIdleTime (default: 2000) + * - Maximum delay between frames in ms + * - Ignored if the `frameDelay` isn't set to `auto` + * - Set to `auto` to prevnt limiting the max idle time + * + * - speedFactor (default: 1) + * - Multiply the frames delays by this factor + * + * @param {Array} records + * @param {Function} playCallback + * @param {Function|Null} doneCallback + * @param {Object} options (optional) + */ + function play(records, playCallback, doneCallback, options) { + + var tasks = []; + + // Default value for options + if (typeof options === 'undefined') { + options = {}; + } + + // Default value for options.frameDelay + if (typeof options.frameDelay === 'undefined') { + options.frameDelay = 'auto'; + } + + // Default value for options.maxIdleTime + if (typeof options.maxIdleTime === 'undefined') { + options.maxIdleTime = 2000; + } + + // Default value for options.speedFactor + if (typeof options.speedFactor === 'undefined') { + options.speedFactor = 1; + } + + // Foreach record + records.forEach(function(record, index) { + + // Create a task to handle each frame + tasks.push(function(callback) { + + var delay = record.delay; + + // Adjust the delay according to the options + if (options.frameDelay != 'auto') { + delay = options.frameDelay; + } else if (options.maxIdleTime != 'auto' && delay > options.maxIdleTime) { + delay = options.maxIdleTime; + } + + // Apply speedFactor + delay = delay * options.speedFactor; + + // Add an index to the record object + record.index = index; + + setTimeout(function() { + playCallback(record, callback); + }, delay); + + }); + + }); + + async.series(tasks, function(error, results) { + + if (doneCallback) { + doneCallback(); + } + + }); + + } + +}(jQuery)); diff --git a/package.json b/package.json new file mode 100644 index 0000000..53550ac --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "terminalizer", + "version": "0.1.0", + "description": "Record your terminal and generate animated gif images", + "main": "bin/app.js", + "author": "Mohammad Fares ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/faressoft/terminalizer.git" + }, + "bin": { + "terminalizer": "bin/app.js" + }, + "dependencies": { + "async": "^2.6.1", + "async-promises": "^0.2.1", + "death": "^1.1.0", + "deepmerge": "^2.1.0", + "electron": "^2.0.5", + "fs-extra": "^5.0.0", + "gif-encoder": "^0.6.1", + "is_js": "^0.9.0", + "jquery": "^3.3.1", + "js-yaml": "^3.11.0", + "node-pty-prebuilt": "^0.7.3", + "performance-now": "^2.1.0", + "pngjs": "^3.3.2", + "progress": "^2.0.0", + "string-argv": "0.0.2", + "xterm": "^3.5.1", + "yargs": "^12.0.1" + } +} diff --git a/render/index.html b/render/index.html new file mode 100644 index 0000000..8ede8db --- /dev/null +++ b/render/index.html @@ -0,0 +1,124 @@ + + + + Terminalizer + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/render/index.js b/render/index.js new file mode 100644 index 0000000..0986bc1 --- /dev/null +++ b/render/index.js @@ -0,0 +1,63 @@ +/** + * Render the frames into PNG images + * An electron app, takes one command line argument `step` + * + * @author Mohammad Fares + */ + +var path = require('path'), + app = require('electron').app, + BrowserWindow = require('electron').BrowserWindow, + ipcMain = require('electron').ipcMain; + +// Set as global to be read by the web page +global.step = process.argv[2] || 1; + +// Hide the Dock for macOS +app.dock.hide(); + +// Set the display scale factor to 1 +app.commandLine.appendSwitch('force-device-scale-factor', 1); + +// When the app is ready +app.on('ready', createWindow); + +/** + * Create a hidden browser window and load the rendering page + */ +function createWindow() { + + // Create a browser window + var win = new BrowserWindow({show: false}); + + // Load index.html + win.loadURL('file://' + __dirname + '/index.html'); + + // Maximize the window + win.maximize(); + +} + +/** + * A callback function for the event: + * When a frame is captured + * + * @param {Number} recordIndex + */ +ipcMain.on('captured', function(recordIndex) { + + console.log(recordIndex); + +}); + +/** + * A callback function for the event: + * When something unexpected happened + * + * @param {String} errorMsg + */ +ipcMain.on('error', function(errorMsg) { + + console.error(errorMsg); + +}); diff --git a/utility.js b/utility.js new file mode 100644 index 0000000..89cc521 --- /dev/null +++ b/utility.js @@ -0,0 +1,171 @@ +/** + * Provide utility functions + * + * @author Mohammad Fares + */ + +/** + * Check, load, and parse YAML files + * + * - Add .yml extension when needed + * + * Throws + * - The provided file doesn't exit + * - The provided file is not a valid YAML file + * + * @param {String} filePath an absolute or a relative path + * @return {Object} + */ +function loadYAML(filePath) { + + var file = null; + + // Rsolve the path into an absolute path + filePath = di.path.resolve(filePath); + + // The file doesn't exist + if (!di.fs.existsSync(filePath)) { + + // A file with .yml suffix also doesn't exist + if (!di.fs.existsSync(filePath + '.yml')) { + throw new Error('The provided file doesn\'t exit'); + } else { + filePath = filePath + '.yml'; + } + + } + + // Read the file + try { + file = di.fs.readFileSync(filePath); + } catch (error) { + throw new Error(error); + } + + // Parse the file + try { + + return { + json: di.yaml.load(file), + raw: file.toString() + }; + + } catch (error) { + + throw new Error('The provided file is not a valid YAML file'); + + } + +} + +/** + * Check, load, and parse JSON files + * + * - Add .json extension when needed + * + * Throws + * - The provided file doesn't exit + * - The provided file is not a valid JSON file + * + * @param {String} filePath an absolute or a relative path + * @return {Object} + */ +function loadJSON(filePath) { + + var file = null; + + // Rsolve the path into an absolute path + filePath = di.path.resolve(filePath); + + // The file doesn't exist + if (!di.fs.existsSync(filePath)) { + + // A file with .json suffix also doesn't exist + if (!di.fs.existsSync(filePath + '.json')) { + throw new Error('The provided file doesn\'t exit'); + } else { + filePath = filePath + '.json'; + } + + } + + // Read the file + try { + file = di.fs.readFileSync(filePath); + } catch (error) { + throw new Error(error); + } + + // Parse the file + try { + return JSON.parse(file); + } catch (error) { + throw new Error('The provided file is not a valid JSON file'); + } + +} + +/** + * Resolve a path and add an extension to the file name + * + * - Add the extension if not already added + * + * @param {String} filePath an absolute or a relative path + * @param {String} extension + * @return {String} + */ +function resolveFilePath(filePath, extension) { + + var resolvedPath = di.path.resolve(filePath); + + // The extension is not added + if (di.path.extname(resolvedPath) != '.' + extension) { + resolvedPath += '.' + extension; + } + + return resolvedPath; + +} + +/** + * Get the default configurations + * + * @return {Object} {json, raw} + */ +function getDefaultConfig() { + + var filePath = di.path.join(__dirname, 'config.yml'); + + return loadYAML(filePath); + +} + +/** + * Change a value for a specific key in YAML + * + * - Works only with the first level keys + * - Workds only with keys with a single value + * - Apply the changes on the json and raw + * + * @param {Object} data {json, raw} + * @param {String} key + * @param {*} value + */ +function changeYAMLValue(data, key, value) { + + data.json[key] = value; + data.raw = data.raw.replace(new RegExp('^' + key + ':.+$', 'm'), key + ': ' + value); + +} + +//////////////////////////////////////////////////// +// Module ////////////////////////////////////////// +//////////////////////////////////////////////////// + +module.exports = { + loadYAML: loadYAML, + loadJSON: loadJSON, + resolveFilePath: resolveFilePath, + getDefaultConfig: getDefaultConfig, + changeYAMLValue: changeYAMLValue +};