first commit

This commit is contained in:
Mohammad Fares 2018-07-23 01:34:35 +03:00
commit d1153e58ba
28 changed files with 2697 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -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

73
.jscsrc Normal file
View File

@ -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
}
}

369
README.md Normal file
View File

@ -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
<p align="center"><img src="/img/demo.gif?raw=true"/></p>
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
```
<p align="center"><img src="/img/install.gif?raw=true"/></p>
## 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 <command> [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 <recordingFile>
```
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 <recordingFile>
```
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 <recordingFile>
```
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 <recordingFile>
```
### Generate
> Generate a web player for a recording file
```bash
terminalizer generate <recordingFile>
```
## 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: <code style="background-color: #afafaf">#afafaf</code>.
* cursor: <code style="background-color: #c7c7c7">#c7c7c7</code>.
* black: <code style="background-color: #232628;">#232628</code>.
* red: <code style="background-color: #fc4384">#fc4384</code>.
* green: <code style="background-color: #b3e33b">#b3e33b</code>.
* yellow: <code style="background-color: #ffa727">#ffa727</code>.
* blue: <code style="background-color: #75dff2">#75dff2</code>.
* magenta: <code style="background-color: #ae89fe">#ae89fe</code>.
* cyan: <code style="background-color: #708387">#708387</code>.
* white: <code style="background-color: #d5d5d0">#d5d5d0</code>.
* brightBlack: <code style="background-color: #626566">#626566</code>.
* brightRed: <code style="background-color: #ff7fac">#ff7fac</code>.
* brightGreen: <code style="background-color: #c8ed71">#c8ed71</code>.
* brightYellow: <code style="background-color: #ebdf86">#ebdf86</code>.
* brightBlue: <code style="background-color: #75dff2">#75dff2</code>.
* brightMagenta: <code style="background-color: #ae89fe">#ae89fe</code>.
* brightCyan: <code style="background-color: #b1c6ca">#b1c6ca</code>.
* brightWhite: <code style="background-color: #f9f9f4">#f9f9f4</code>.
## Watermark
You can add a watermark logo to your generated GIF images.
<p align="center"><img src="/img/watermark.gif?raw=true"/></p>
```
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.
<p align="center"><img src="/img/frames/null.gif?raw=true"/></p>
> Don't forget to add a `backgroundColor` under `style`.
```
frameBox:
type: null
title: null
style:
backgroundColor: black
```
### Window Frame
<p align="center"><img src="/img/frames/window.gif?raw=true"/></p>
```
frameBox:
type: window
title: Terminalizer
style: []
```
### Floating Frame
<p align="center"><img src="/img/frames/floating.gif?raw=true"/></p>
```
frameBox:
type: floating
title: Terminalizer
style: []
```
### Solid Frame
<p align="center"><img src="/img/frames/solid.gif?raw=true"/></p>
```
frameBox:
type: solid
title: Terminalizer
style: []
```
### Solid Frame Without Title
<p align="center"><img src="/img/frames/solid_without_title.gif?raw=true"/></p>
```
frameBox:
type: solid
title: null
style: []
```
### Styling Hint
You can disable the default shadows and margins by:
<p align="center"><img src="/img/frames/solid_without_title_without_shadows.gif?raw=true"/></p>
```
frameBox:
type: solid
title: null
style:
boxShadow: none
margin: 0px
```
# License
This project is under the MIT license.

96
app.js Normal file
View File

@ -0,0 +1,96 @@
/**
* Terminalizer
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
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 <command> [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);
}

9
bin/app.js Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env node
/**
* Terminalizer
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
require('../app.js');

53
commands/config.js Normal file
View File

@ -0,0 +1,53 @@
/**
* Config
* Generate a config file in the current directory
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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;

65
commands/generate.js Normal file
View File

@ -0,0 +1,65 @@
/**
* Generate
* Generate a web player for a recording file
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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 <recordingFile>';
/**
* 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
});
};

231
commands/play.js Normal file
View File

@ -0,0 +1,231 @@
/**
* Play
* Play a recording file on your terminal
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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 <recordingFile>';
/**
* 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;

273
commands/record.js Normal file
View File

@ -0,0 +1,273 @@
/**
* Record
* Record your terminal and create a recording file
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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 <recordingFile>';
/**
* 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');
};

381
commands/render.js Normal file
View File

@ -0,0 +1,381 @@
/**
* Render
* Render a recording file as an animated gif image
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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 <recordingFile>';
/**
* 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
});
};

65
commands/share.js Normal file
View File

@ -0,0 +1,65 @@
/**
* Share
* Upload a recording file and get a link for an online player
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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 <recordingFile>';
/**
* 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
});
};

106
config.yml Normal file
View File

@ -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"

43
di.js Normal file
View File

@ -0,0 +1,43 @@
/**
* Dependency Injection
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
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;
};

BIN
img/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
img/frames/floating.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
img/frames/null.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
img/frames/solid.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
img/frames/window.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

BIN
img/install.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
img/watermark.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

222
lib/terminalizer.css Normal file
View File

@ -0,0 +1,222 @@
/**
* Terminalizer Web Plugin
* https://terminalizer.com
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
.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;
}

300
lib/terminalizer.js Normal file
View File

@ -0,0 +1,300 @@
/**
* Terminalizer Web Plugin
* https://terminalizer.com
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
(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 = '<div class="terminalizer">' +
'<div class="terminalizer-frame"><div class="terminalizer-titlebar">' +
'<div class="buttons"><div class="close"></div><div class="minimize">' +
'</div><div class="maximize"></div></div><div class="title"></div>' +
'</div><div class="terminalizer-body"></div></div></div>';
// 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));

34
package.json Normal file
View File

@ -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 <faressoft.com@gmail.com>",
"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"
}
}

124
render/index.html Normal file
View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html>
<head>
<title>Terminalizer</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="../node_modules/xterm/dist/xterm.css">
<link rel="stylesheet" href="../lib/terminalizer.css">
<style type="text/css">
body {
background-color: white;
margin: 0;
}
#terminal {
display: inline-block;
font-size: 0px;
}
</style>
</head>
<body>
<div id="terminal"></div>
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
<script src="../node_modules/xterm/dist/xterm.js"></script>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/async/dist/async.min.js"></script>
<script src="../lib/terminalizer.js"></script>
<script type="text/javascript">
var fs = require('fs'),
path = require('path'),
remote = require('electron').remote,
ipcRenderer = require('electron').ipcRenderer;
var currentWindow = remote.getCurrentWindow(),
capturePage = currentWindow.webContents.capturePage,
step = remote.getGlobal('step');
/**
* Used for the step option
* @type {Number}
*/
var stepsCounter = 0;
/**
* Options for the terminalizer plugin
* @type {Object}
*/
var options = {
recordingFile: 'data.json',
frameDelay: 0
};
/**
* A middleware that called after rendering frames
*
* @param {Object} record {delay, content, index}
* @param {Function} next
*/
options.afterMiddleware = function(record, next) {
var width = this.width();
var height = this.height();
var captureRect = {x: 0, y: 0, width: width, height: height};
if (stepsCounter != 0) {
stepsCounter = (stepsCounter + 1) % step;
return next();
}
stepsCounter = (stepsCounter + 1) % step;
// A workaround by a delay to make sure the record is rendered
setTimeout(function() {
capturePage(captureRect, function(img) {
var outputPath = path.join(__dirname, '/frames/' + record.index + '.png');
fs.writeFileSync(outputPath, img.toPNG());
ipcRenderer.send('captured', record.index);
next();
});
}, 10);
};
// Initialize the terminalizer plugin
$('#terminal').terminalizer(options);
/**
* A callback function for the event:
* playingDone
*/
$('#terminal').on('playingDone', function() {
currentWindow.close();
});
/**
* Catch all unhandled errors
*
* @param {String} errorMsg
*/
window.onerror = function(errorMsg) {
ipcRenderer.send('error', errorMsg);
};
</script>
<script>if (window.module) module = window.module;</script>
</body>
</html>

63
render/index.js Normal file
View File

@ -0,0 +1,63 @@
/**
* Render the frames into PNG images
* An electron app, takes one command line argument `step`
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
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);
});

171
utility.js Normal file
View File

@ -0,0 +1,171 @@
/**
* Provide utility functions
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
/**
* 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
};