├── .babelrc ├── .npmignore ├── History.md ├── Makefile ├── Readme.md ├── bin └── clif ├── lib ├── bashrc ├── client.js ├── command.js ├── index.js ├── phantom-page.js ├── phantom.js ├── process-frame.js ├── process.js ├── to-worker.js └── worker.js ├── node └── .gitignore └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 0.0.6 / 2015-11-17 3 | ================== 4 | 5 | * package: add `postinstall` 6 | 7 | 0.0.5 / 2015-11-17 8 | ================== 9 | 10 | * fixed build 11 | 12 | 0.0.4 / 2015-11-17 13 | ================== 14 | 15 | * package: use modern babel polyfills/presets 16 | * bumped babel and fixed build 17 | 18 | 0.0.3 / 2015-11-16 19 | ================== 20 | 21 | * switched `6to5` with `babel` and thus fixed 22 | `kexec` warnings 23 | 24 | 0.0.2 / 2015-11-16 25 | ================== 26 | 27 | * bump dependencies for node 4 compat 28 | 29 | 0.0.1 / 2015-02-12 30 | ================== 31 | 32 | * initial release 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | BABEL = ./node_modules/.bin/babel 3 | 4 | node: lib/*.js 5 | @$(BABEL) lib --out-dir node 6 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # clif 3 | 4 | Cross-platform CLI GIF maker based on JS+Web. 5 | 6 | ![](https://cldup.com/Iu3VmK9SVy.gif) 7 | 8 | ## How to use 9 | 10 | Run 11 | 12 | ```bash 13 | $ clif out.gif 14 | ``` 15 | 16 | type `exit` to finish and save the recording. 17 | 18 | ## Features 19 | 20 | - Easy to install: `npm install -g clif`. 21 | - Works on OSX and Linux. 22 | - Small GIFs. 23 | - High quality (anti-aliased fonts). 24 | - Rendered with CSS/JS, customizable. 25 | - Realtime parallel rendering. 26 | - Frame aggregation and customizable FPS. 27 | - Support for titles Terminal.app-style. 28 | 29 | ## How it works 30 | 31 | clif builds mainly on four projects: `child_pty`, `term.js` 32 | `omggif` and `phantomjs`. 33 | 34 | `child_pty` is used to spawn a pseudo terminal from 35 | which we can capture the entirety of input and output. 36 | 37 | Each frame that's captured is asynchronously sent to 38 | a `phantomjs` headless browser to render using `term.js` 39 | and screenshot. 40 | 41 | The GIF is composited with `omggif` and finally written 42 | out to the filesystem. 43 | 44 | ## Options 45 | 46 | ``` 47 | 48 | Usage: clif [options] 49 | 50 | Options: 51 | 52 | -h, --help output usage information 53 | -V, --version output the version number 54 | -c, --cols Cols of the term [90] 55 | -r, --rows Rows of the term [30] 56 | -s, --shell Shell to use [/bin/bash] 57 | -f, --fps Frames per second [8] 58 | -q, --quality Frame quality 1-30 (1 = best|slowest) [5] 59 | 60 | ``` 61 | 62 | ## TODO 63 | 64 | - Substitute `phantom` with a terminal rendered on top 65 | of `node-canvas` or low-level graphic APIs. 66 | [terminal.js](https://github.com/Gottox/terminal.js) seems like a good 67 | candidate to add a `` adaptor to. 68 | - Should work on Windows with some minor tweaks. 69 | 70 | ## Credits 71 | 72 | - Inspired by [KeyboardFire](https://github.com/KeyboardFire)'s [mkcast](https://github.com/KeyboardFire/mkcast). 73 | - Borrows GIF palette neuquant indexing from 74 | [sole](https://github.com/sole)'s [animated_GIF.js](https://github.com/sole/Animated_GIF). 75 | 76 | ## License 77 | 78 | MIT 79 | -------------------------------------------------------------------------------- /bin/clif: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../node/command'); 4 | -------------------------------------------------------------------------------- /lib/bashrc: -------------------------------------------------------------------------------- 1 | if [ -f ~/.bashrc ]; then 2 | . ~/.bashrc 3 | elif [ -f ~/.bash_profile ]; then 4 | . ~/.bash_profile 5 | elif [ -f ~/.profile ]; then 6 | . ~/.profile 7 | fi 8 | 9 | export PS1="\[\033[91m\] ● \[\033[00m\]$PS1" 10 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | 2 | // deps 3 | var Terminal = require('term.js'); 4 | 5 | // rpc 6 | var _ = window._ = {}; 7 | 8 | // term ref 9 | var term; 10 | 11 | // dom 12 | var style = document.createElement('style'); 13 | style.innerText = [ 14 | '* {', 15 | 'margin: 0;', 16 | 'padding: 0;', 17 | 'box-sizing: border-box;', 18 | '}', 19 | '.terminal {', 20 | 'font-family: Menlo, monospace;', 21 | 'font-size: 11px;', 22 | '}', 23 | '.toolbar {', 24 | 'height: 20px;', 25 | 'line-height: 20px;', 26 | 'vertical-align: middle;', 27 | 'background: #DEDEDE;', 28 | 'text-align: center;', 29 | '}', 30 | '.toolbar > div {', 31 | 'vertical-align: middle;', 32 | 'height: 20px;', 33 | 'line-height: 20px;', 34 | 'font-family: Menlo, Monaco, monospace;', 35 | '}', 36 | '.toolbar .title {', 37 | 'color: #4A4A4A;', 38 | 'font-size: 10px;', 39 | 'width: 50%;', 40 | 'margin: auto;', 41 | '}', 42 | '.toolbar .credits {', 43 | 'color: #A9A9A9;', 44 | 'font-size: 9px;', 45 | 'float: right;', 46 | 'padding-right: 6px;', 47 | '}', 48 | '.balls {', 49 | 'float: left;', 50 | 'padding-left: 6px;', 51 | '}', 52 | '.ball {', 53 | 'display: inline-block;', 54 | 'width: 10px;', 55 | 'height: 10px;', 56 | 'border-radius: 100%;', 57 | 'margin-right: 4px;', 58 | '}', 59 | '.keys {', 60 | 'height: 20px;', 61 | 'line-height: 20px;', 62 | 'vertical-align: middle;', 63 | 'background: #4A4A4A;', 64 | 'margin-right: 4px;', 65 | 'font-family: Menlo, Monaco, monospace;', 66 | 'font-size: 10px;', 67 | 'padding: 0 6px;', 68 | '}', 69 | '.keys :first-child {', 70 | 'color: #DEDEDE;', 71 | '}', 72 | '.keys :nth-child(2) {', 73 | 'color: #9C9C9C;', 74 | '}', 75 | '.keys :nth-child(3) {', 76 | 'color: #8E8F8E;', 77 | '}', 78 | ].join(''); 79 | var div = document.createElement('div'); 80 | 81 | // whether the title was user set 82 | // through xterm ansi escapes 83 | var userTitle = false; 84 | 85 | // set up terminal 86 | _.setup = function(opts){ 87 | document.body.appendChild(style); 88 | document.body.appendChild(div); 89 | 90 | term = new Terminal({ 91 | rows: opts.rows, 92 | cols: opts.cols, 93 | useStyle: true, 94 | screenKeys: opts.screenKeys, 95 | cursorBlink: false 96 | }); 97 | term.open(div); 98 | 99 | var w = div.childNodes[0].offsetWidth + 'px'; 100 | 101 | if (opts.toolbar) { 102 | var bar = toolbar(opts.version); 103 | bar.style.width = w; 104 | document.body.insertBefore(bar, div); 105 | term._toolbar = bar; 106 | 107 | term.on('title', function(title){ 108 | userTitle = true; 109 | bar._title.innerText = title; 110 | }); 111 | } 112 | 113 | if (opts.keys) { 114 | var keys = document.createElement('div'); 115 | keys.style.width = w; 116 | keys.className = 'keys'; 117 | term._keys = keys; 118 | } 119 | }; 120 | 121 | // receive frame 122 | _.frame = function(frame){ 123 | term.write(frame.data); 124 | var keys = frame.keys; 125 | if (keys.length && term._keys) { 126 | var el = term._keys; 127 | 128 | var span = document.createElement('span'); 129 | span.innerText = keys.map(function(key){ 130 | // modifier 131 | var mod = ''; 132 | if (key.ctrl) mod += 'ctrl+'; 133 | if (key.shift) mod += 'shift+'; 134 | if (key.meta) mod += 'meta+'; 135 | 136 | // key 137 | var name = key.name; 138 | if (name == 'return') name = '↵'; 139 | 140 | return mod + name; 141 | }).join(' '); 142 | 143 | if (el.childNodes.length) { 144 | el.insertBefore(span, el.childNodes[0]); 145 | } else { 146 | el.appendChild(span); 147 | } 148 | 149 | // trim to first 3 150 | for (var i = 3; i < el.childNodes.length; i++) { 151 | var child = el.childNodes[i]; 152 | child.parentNode.removeChild(child); 153 | } 154 | } 155 | }; 156 | 157 | // set the title 158 | _.title = function(title){ 159 | if (!userTitle) { 160 | term._toolbar._title.innerText = title; 161 | } 162 | }; 163 | 164 | // create toolbar 165 | function toolbar(version){ 166 | var bar = document.createElement('div'); 167 | bar.className = 'toolbar'; 168 | 169 | var balls = document.createElement('div'); 170 | balls.className = 'balls'; 171 | balls.appendChild(ball('#999')); 172 | balls.appendChild(ball('#999')); 173 | balls.appendChild(ball('#999')); 174 | bar.appendChild(balls); 175 | 176 | var credits = document.createElement('div'); 177 | credits.className = 'credits'; 178 | credits.innerText = 'clif ' + version; 179 | bar.appendChild(credits); 180 | 181 | var title = document.createElement('div'); 182 | title.className = 'title'; 183 | bar._title = title; 184 | bar.appendChild(title); 185 | 186 | function ball(color){ 187 | var div = document.createElement('div'); 188 | div.className = 'ball'; 189 | div.style.backgroundColor = color; 190 | return div; 191 | } 192 | 193 | return bar; 194 | } 195 | -------------------------------------------------------------------------------- /lib/command.js: -------------------------------------------------------------------------------- 1 | import pkg from '../package'; 2 | import program from 'commander'; 3 | import Clif from './'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import exec from 'child_process'; 7 | 8 | const sh = process.env.SHELL || 'bash'; 9 | 10 | program 11 | .version(pkg.version) 12 | .usage('[options] ') 13 | .option('-c, --cols ', 'Cols of the term [90]', 90) 14 | .option('-r, --rows ', 'Rows of the term [30]', 30) 15 | .option('-s, --shell ', 'Shell to use [' + sh + ']', sh) 16 | .option('-f, --fps ', 'Frames per second [8]', 8) 17 | .option('-q, --quality ', 'Frame quality 1-30 (1 = best|slowest) [5]', 5) 18 | .option('-T, --no-toolbar', 'Don\'t show top bar [false]', false) 19 | //.option('-K, --no-keys', 'No screen keys [false]', false) 20 | .parse(process.argv); 21 | 22 | if (program.args.length != 1) { 23 | program.help(); 24 | } else { 25 | program.file = program.args[0]; 26 | } 27 | 28 | console.log(''); 29 | console.log(' \u001b[91m● Now recording! \u001b[39m'); 30 | console.log(' \u001b[90m● Write "exit" to finish the session. \u001b[39m'); 31 | console.log(''); 32 | 33 | const argv = []; 34 | 35 | // we're using `bash`? 36 | if (/\bbash$/.test(sh)) { 37 | const rc = path.resolve(__dirname, '..', 'lib', 'bashrc'); 38 | argv.push('--rcfile', rc); 39 | } 40 | 41 | const clif = new Clif({ 42 | argv: argv, 43 | rows: program.rows, 44 | cols: program.cols, 45 | shell: program.shell, 46 | cwd: process.cwd(), 47 | fps: program.fps, 48 | quality: program.quality, 49 | toolbar: program.toolbar, 50 | keys: program.keys 51 | }); 52 | 53 | clif.on('process', function(){ 54 | console.log(''); 55 | 56 | if (clif.pending) { 57 | console.log(' \u001b[90m◷ Recording complete! %d frames left…\u001b[39m', clif.pending); 58 | } else { 59 | console.log(' \u001b[90m◷ Recording complete!\u001b[39m'); 60 | } 61 | 62 | process.stdout.write(' '); 63 | 64 | if (clif.pending) { 65 | clif.on('process frame', function(){ 66 | process.stdout.write('\u001b[90m.\u001b[39m'); 67 | }); 68 | } 69 | 70 | clif.on('complete', function(gif){ 71 | fs.writeFileSync(program.file, gif); 72 | console.log('\n \u001b[96m✓ Written to "%s".\u001b[39m\n', program.file); 73 | }); 74 | }); 75 | 76 | process.on('exit', function(){ 77 | clif.cleanup(); 78 | }); 79 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 2 | require('babel-polyfill'); 3 | 4 | import { exec } from 'child_process'; 5 | import { EventEmitter } from 'events'; 6 | import { spawn as pty } from 'child_pty'; 7 | import clone from 'clone'; 8 | import keypress from 'keypress'; 9 | import Worker from './worker'; 10 | 11 | class Clif extends EventEmitter { 12 | 13 | constructor({ shell, fps, env, rows, cols, keys, toolbar, quality, cwd, argv }){ 14 | super(); 15 | 16 | // settings 17 | this.fps = fps || 8; 18 | this.cols = cols || 90; 19 | this.rows = rows || 30; 20 | this.quality = quality || 20; 21 | this.keys = !!keys; 22 | this.toolbar = !!toolbar; 23 | 24 | // state 25 | this.pending = 0; 26 | this.keybuf = []; 27 | 28 | this.worker = new Worker({ 29 | fps: this.fps, 30 | cols: this.cols, 31 | rows: this.rows, 32 | keys: this.keys, 33 | toolbar: this.toolbar, 34 | quality: this.quality 35 | }); 36 | 37 | env = clone(env || process.env); 38 | env.TERM = env.TERM || 'xterm-256color'; 39 | 40 | this.pty = pty(shell, argv, { 41 | cwd: cwd, 42 | rows: this.rows, 43 | columns: this.cols, 44 | env: env 45 | }); 46 | 47 | // in 48 | if (this.keys) keypress(process.stdin); 49 | this.rawState = process.stdin.isRaw; 50 | process.stdin.setRawMode(true); 51 | process.stdin.resume(); 52 | process.stdin.pipe(this.pty.stdin); 53 | process.stdin.on('keypress', (c, k) => this.key(c, k)); 54 | 55 | // out 56 | this.pty.stdout.on('data', buf => this.out(buf)); 57 | this.pty.on('exit', () => this.complete()); 58 | 59 | // poll title 60 | if (this.toolbar) this.getTitle(); 61 | } 62 | 63 | out(buf){ 64 | if (null == this.pendingFrame) { 65 | // first frame always flushed 66 | this.pendingFrame = ''; 67 | this.frame(buf); 68 | } else { 69 | this.pendingFrame += buf; 70 | 71 | if (!this.flushTimer) { 72 | this.flushTimer = setTimeout(() => { 73 | this.frame(this.pendingFrame); 74 | this.flushTimer = null; 75 | this.pendingFrame = ''; 76 | this.emit('flush'); 77 | }, Math.round(1000 / this.fps)); 78 | } 79 | } 80 | 81 | process.stdout.write(buf); 82 | } 83 | 84 | key(c, k){ 85 | this.keybuf.push(k); 86 | } 87 | 88 | frame(buf){ 89 | if (null == buf) buf = ''; 90 | 91 | // pre-process buf to replace the 92 | // prefixed symbol hoping it has 93 | // no unanticipated side-effects 94 | let re = /\u001b\[91m ● \u001b\[00m/g; 95 | buf = buf.toString('utf8').replace(re, ''); 96 | 97 | this.pending++; 98 | this.worker.frame({ 99 | at: Date.now(), 100 | data: buf, 101 | keys: this.keybuf 102 | }) 103 | .then(() => { 104 | this.pending--; 105 | this.emit('process frame'); 106 | }); 107 | 108 | this.keybuf = []; 109 | } 110 | 111 | getTitle(){ 112 | let tty = this.pty.stdout.ttyname; 113 | tty = tty.replace(/^\/dev\/tty/, ''); 114 | 115 | // try to exclude grep from the results 116 | // by grepping for `[s]001` instead of `s001` 117 | tty = `[${tty[0]}]${tty.substr(1)}`; 118 | 119 | exec(`ps c | grep ${tty} | tail -n 1`, (err, out) => { 120 | if (this.ended) return; 121 | if (err) return; 122 | let title = out.split(' ').pop(); 123 | if (title) { 124 | title = title.replace(/^\(/, ''); 125 | title = title.replace(/\)?\n$/, ''); 126 | if (title != this.lastTitle) { 127 | this.worker.title(title); 128 | this.lastTitle = title; 129 | } 130 | } 131 | this.titlePoll = setTimeout(() => this.getTitle(), 500); 132 | }); 133 | } 134 | 135 | cleanup(){ 136 | if (this.ended) return; 137 | process.stdin.setRawMode(this.rawState); 138 | process.stdin.unpipe(this.pty); 139 | process.stdin.pause(); 140 | this.pty.kill(); 141 | this.worker.end(); 142 | clearTimeout(this.titlePoll); 143 | this.ended = true; 144 | } 145 | 146 | complete(){ 147 | let done = () => { 148 | this.emit('process'); 149 | this.worker.complete() 150 | .then(obj => { 151 | this.emit('complete', new Buffer(obj)); 152 | this.cleanup(); 153 | }) 154 | .catch(err => this.emit(err)); 155 | }; 156 | 157 | if (this.pendingFrame.length) { 158 | this.once('flush', done); 159 | } else { 160 | done(); 161 | } 162 | } 163 | 164 | // takes the buffer of instructions 165 | compress(){ 166 | let buf = this.buffer; 167 | 168 | // frames we're gonna merge 169 | let add = []; 170 | let dur = 1000 / this.fps; 171 | let anchor = +buf[0].at; 172 | 173 | let cnt = Math.ceil((buf[buf.length - 1].at - buf[0].at) / dur); 174 | for (let i = 0; i < cnt; i++) { 175 | let add = []; 176 | for (var f = 0; f < buf.length; f++) { 177 | let frame = buf[f]; 178 | if (+frame.at <= anchor) { 179 | add.push(frame); 180 | } else { 181 | break; 182 | } 183 | } 184 | 185 | if (add.length) { 186 | buf.splice(0, f); 187 | this.frames.push(add.reduce((prev, next) => { 188 | if (!prev.at) prev.at = next.at; 189 | prev.data = (prev.data || '') + next.data; 190 | return prev; 191 | }, {})); 192 | } 193 | anchor += dur; 194 | } 195 | } 196 | 197 | } 198 | 199 | export default Clif; 200 | -------------------------------------------------------------------------------- /lib/phantom-page.js: -------------------------------------------------------------------------------- 1 | /*global phantom*/ 2 | 3 | var system = require('system'); 4 | if (system.args.length === 1) { 5 | console.log('missing args'); 6 | phantom.exit(); 7 | } 8 | var port = system.args[1]; 9 | var page = require('webpage').create(); 10 | var _ = {}; 11 | var ws; 12 | 13 | page.open('http://localhost:' + port, function(status){ 14 | if ('success' != status) { 15 | console.log('page open problem'); 16 | phantom.exit(); 17 | } 18 | 19 | ws = new WebSocket('ws://localhost:' + port); 20 | ws.onmessage = function(ev){ 21 | var packet = JSON.parse(ev.data); 22 | var name = packet[0]; 23 | var args = packet[1]; 24 | _[name].apply(_, args); 25 | }; 26 | }); 27 | 28 | _.setup = function(opts){ 29 | call('setup', [opts]); 30 | emit('setup'); 31 | }; 32 | 33 | _.frame = function(frame, file){ 34 | call('frame', [frame]); 35 | page.render(file); 36 | emit('frame'); 37 | }; 38 | 39 | _.title = function(title){ 40 | call('title', [title]); 41 | emit('title'); 42 | }; 43 | 44 | // respond to node.js 45 | function emit(event, params){ 46 | var packet = JSON.stringify([event, params]); 47 | ws.send(packet); 48 | } 49 | 50 | // call to loaded page 51 | function call(fn, params){ 52 | var json = JSON.stringify(params) 53 | .replace("\u2028", "\\u2028") 54 | .replace("\u2029", "\\u2029"); 55 | var js = 'function(){_.' + fn + '.apply(null, ' + json + ')}'; 56 | page.evaluateJavaScript(js); 57 | } 58 | -------------------------------------------------------------------------------- /lib/phantom.js: -------------------------------------------------------------------------------- 1 | 2 | import express from 'express'; 3 | import browserify from 'browserify-middleware'; 4 | import { readFile, unlink } from 'fs'; 5 | import { Server as WS } from 'ws'; 6 | import { Server as HTTP } from 'http'; 7 | import { spawn } from 'child_process'; 8 | import { join } from 'path'; 9 | import { path as phantom } from 'phantomjs'; 10 | import { EventEmitter } from 'events'; 11 | import { tmpdir } from 'osenv'; 12 | import uid from 'uid2'; 13 | 14 | class Phantom extends EventEmitter { 15 | 16 | constructor({ rows, cols, keys, toolbar }) { 17 | super(); 18 | 19 | this.rows = rows; 20 | this.cols = cols; 21 | this.keys = keys; 22 | this.toolbar = toolbar; 23 | 24 | let app = express(); 25 | let http = HTTP(app); 26 | let ws = new WS({ server: http }); 27 | 28 | app.use('/script.js', browserify(join(__dirname, 'client.js'))); 29 | app.get('/', (req, res, next) => { 30 | res.send(` 31 | 32 | 33 | `); 34 | }); 35 | 36 | http.listen(err => { 37 | if (err) return this.emit('error', err); 38 | 39 | let port = http.address().port; 40 | 41 | ws.on('connection', socket => { 42 | this.onsocket(socket); 43 | }); 44 | 45 | // launch phantom proc 46 | let proc = spawn(phantom, [ 47 | join(__dirname, '/phantom-page.js'), 48 | port 49 | ]); 50 | 51 | proc.stdout.on('data', function(buf){ 52 | console.error(buf.toString()); 53 | }); 54 | 55 | proc.stderr.on('data', function(buf){ 56 | console.error(buf.toString()); 57 | }); 58 | 59 | this.proc = proc; 60 | }); 61 | 62 | this.http = http; 63 | } 64 | 65 | frame(frame, fn){ 66 | let file = join(tmpdir(), uid(8) + '.png'); 67 | this.call('frame', [frame, file]); 68 | this.once('frame', function(){ 69 | readFile(file, (err, buf) => { 70 | if (err) return this.emit('error', err); 71 | fn(buf); 72 | unlink(file); 73 | }); 74 | }); 75 | } 76 | 77 | title(title, fn){ 78 | this.call('title', [title]); 79 | this.once('title', fn); 80 | } 81 | 82 | call(name, params){ 83 | let packet = [name, params]; 84 | this.socket.send(JSON.stringify(packet)); 85 | } 86 | 87 | onsocket(socket){ 88 | this.socket = socket; 89 | socket.on('message', data => { 90 | let args = JSON.parse(data); 91 | this.emit.apply(this, args); 92 | }); 93 | this.call('setup', [{ 94 | rows: this.rows, 95 | cols: this.cols, 96 | keys: this.keys, 97 | toolbar: this.toolbar, 98 | version: require('../package').version 99 | }]); 100 | this.once('setup', () => { 101 | this.emit('ready'); 102 | }); 103 | } 104 | 105 | destroy(){ 106 | this.http.close(); 107 | this.proc.kill(); 108 | } 109 | 110 | } 111 | 112 | export default Phantom; 113 | -------------------------------------------------------------------------------- /lib/process-frame.js: -------------------------------------------------------------------------------- 1 | 2 | import NeuQuant from 'neuquant'; 3 | 4 | // from https://github.com/sole/Animated_GIF/blob/master/src/Animated_GIF.worker.js 5 | // optimized to avoid neuquantizing when the number of pixels is 6 | // below the per-frame palette threshold 7 | 8 | export default function process(imageData, width, height, sampleInterval) { 9 | var numberPixels = width * height; 10 | 11 | // optimization: try to avoid neuquantizing 12 | // if the palette fits in 256 colors 13 | { 14 | let i = 0; 15 | let length = width * height * 4; 16 | let palette = new Map; 17 | 18 | while(i < length){ 19 | let r = imageData[i++]; 20 | let g = imageData[i++]; 21 | let b = imageData[i++]; 22 | palette.set(r << 16 | g << 8 | b, true); 23 | i++; 24 | } 25 | 26 | if (palette.size <= 256) { 27 | let paletteArray = Array.from(palette.keys()); 28 | paletteArray.length = 256; 29 | let pixels = new Uint8Array(numberPixels); 30 | let p = 0; 31 | let k = 0; 32 | 33 | // produced indexed pixels array 34 | while(k < length) { 35 | let r = imageData[k++]; 36 | let g = imageData[k++]; 37 | let b = imageData[k++]; 38 | let index = r << 16 | g << 8 | b; 39 | pixels[p] = paletteArray.indexOf(index); 40 | k++; p++; 41 | } 42 | 43 | return { 44 | pixels: pixels, 45 | palette: new Uint32Array(paletteArray) 46 | }; 47 | } 48 | } 49 | 50 | var rgbComponents = toRGB( imageData, width, height ); 51 | var nq = new NeuQuant(rgbComponents, rgbComponents.length, sampleInterval); 52 | var paletteRGB = nq.process(); 53 | var paletteArray = new Uint32Array(componentizedPaletteToArray(paletteRGB)); 54 | var indexedPixels = new Uint8Array(numberPixels); 55 | var k = 0; 56 | 57 | for (var i = 0; i < numberPixels; i++) { 58 | var r = rgbComponents[k++]; 59 | var g = rgbComponents[k++]; 60 | var b = rgbComponents[k++]; 61 | indexedPixels[i] = nq.map(r, g, b); 62 | } 63 | 64 | return { 65 | pixels: indexedPixels, 66 | palette: paletteArray 67 | }; 68 | } 69 | 70 | function toRGB(data, width, height) { 71 | var i = 0; 72 | var length = width * height * 4; 73 | var rgb = []; 74 | 75 | while(i < length) { 76 | rgb.push( data[i++] ); 77 | rgb.push( data[i++] ); 78 | rgb.push( data[i++] ); 79 | i++; // for the alpha channel which we don't care about 80 | } 81 | 82 | return rgb; 83 | } 84 | 85 | function componentizedPaletteToArray(paletteRGB) { 86 | var paletteArray = []; 87 | 88 | for(var i = 0; i < paletteRGB.length; i += 3) { 89 | var r = paletteRGB[ i ]; 90 | var g = paletteRGB[ i + 1 ]; 91 | var b = paletteRGB[ i + 2 ]; 92 | paletteArray.push(r << 16 | g << 8 | b); 93 | } 94 | 95 | return paletteArray; 96 | } 97 | 98 | -------------------------------------------------------------------------------- /lib/process.js: -------------------------------------------------------------------------------- 1 | 2 | // ensure polyfills are set since 3 | // this file is required standalone 4 | // as a child process 5 | require('babel-polyfill'); 6 | 7 | import Queue from 'queue3'; 8 | import Phantom from './phantom'; 9 | import processFrame from './process-frame'; 10 | import { PNG } from 'pngjs'; 11 | import { spawn } from 'child_process'; 12 | import { EventEmitter } from 'events'; 13 | import { GifWriter as GIF } from 'omggif'; 14 | 15 | export default class Process { 16 | 17 | constructor({ cols, rows, quality, keys, toolbar, fps }){ 18 | this.cols = cols; 19 | this.rows = rows; 20 | this.quality = quality; 21 | this.debounce = 1000 / fps; 22 | 23 | // queue for frame processing 24 | this.queue = new Queue(); 25 | 26 | // init browser 27 | this.browser = new Phantom({ 28 | cols: this.cols, 29 | rows: this.rows, 30 | keys, 31 | toolbar 32 | }); 33 | 34 | // defer processing until browser is ready 35 | this.queue.push(fn => this.browser.on('ready', fn)); 36 | } 37 | 38 | title(title){ 39 | this.queue.push(fn => { 40 | this.browser.title(title, fn); 41 | }); 42 | } 43 | 44 | frame(data){ 45 | let next; 46 | 47 | if (this.prev) { 48 | next = data; 49 | data = this.prev; 50 | } else { 51 | this.prev = data; 52 | return; 53 | } 54 | 55 | this.prev = next; 56 | 57 | return new Promise((resolve, reject) => { 58 | this.queue.push(fn => { 59 | this.browser.frame(data, buf => { 60 | let delay = next ? next.at - data.at : 0; 61 | resolve(this._addBuffer(buf, delay)); 62 | fn(); 63 | }); 64 | }); 65 | }); 66 | } 67 | 68 | _addBuffer(buf, delay){ 69 | return new Promise((resolve, reject) => { 70 | var png = new PNG(); 71 | png.on('error', reject); 72 | png.on('parsed', () => { 73 | if (!this.encoder) { 74 | let { width, height } = png; 75 | this.width = width; 76 | this.height = height; 77 | this.gif = []; 78 | this.encoder = new GIF(this.gif, width, height, { loop: 0 }); 79 | } 80 | 81 | let { width, height, quality } = this; 82 | let out = processFrame(png.data, width, height, quality); 83 | 84 | this.encoder.addFrame(0, 0, width, height, out.pixels, { 85 | // by spec, delay is specified in hundredths of seconds 86 | delay: Math.round(delay / 10), 87 | palette: out.palette 88 | }); 89 | 90 | resolve(true); 91 | }); 92 | png.write(buf); 93 | }); 94 | } 95 | 96 | complete(){ 97 | return new Promise((resolve, reject) => { 98 | // we always have a pending frame 99 | this.frame(null); 100 | 101 | this.queue.push(fn => { 102 | this.browser.destroy(); 103 | resolve(this.gif); 104 | fn(); 105 | }); 106 | }); 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /lib/to-worker.js: -------------------------------------------------------------------------------- 1 | 2 | // import polyfills because the child 3 | // process will require this same file 4 | require('babel-polyfill'); 5 | 6 | import { fork } from 'child_process'; 7 | import { EventEmitter } from 'events'; 8 | 9 | export default function toWorker(path){ 10 | // TODO: dont rely on dynamic requires and 11 | // subsitute with `import` and perhaps 12 | // a compilation step 13 | let mod = require(path).default; 14 | 15 | let count = 0; 16 | let privates = new WeakMap; 17 | 18 | class Worker extends EventEmitter { 19 | constructor(){ 20 | super(); 21 | 22 | let child = fork(require.resolve('./to-worker')); 23 | let args = Array.from(arguments); 24 | let acks = new EventEmitter; 25 | 26 | // privates 27 | let priv = {}; 28 | priv.child = child; 29 | priv.acks = acks; 30 | privates.set(this, priv); 31 | 32 | child.send({ construct: args, path }); 33 | 34 | child.on('error', err => { 35 | acks.removeAllListeners(); 36 | if (privates.get(this).ended) return; 37 | this.emit('error', err); 38 | }); 39 | 40 | child.on('exit', () => { 41 | acks.removeAllListeners(); 42 | if (privates.get(this).ended) return; 43 | privates.get(this).ended = true; 44 | this.emit('error', new Error('Unexpected worker exit')); 45 | }); 46 | 47 | child.on('message', ack => { 48 | acks.emit(ack.id, ack); 49 | }); 50 | } 51 | 52 | end(){ 53 | let priv = privates.get(this); 54 | if (priv.ended) return; 55 | priv.ended = true; 56 | priv.child.kill(); 57 | } 58 | } 59 | 60 | let methods = Object.getOwnPropertyNames(mod.prototype); 61 | methods.forEach(method => { 62 | // ignore privates 63 | if ('_' != method[0] || 'constructor' != method) { 64 | Worker.prototype[method] = function(){ 65 | let priv = privates.get(this); 66 | 67 | if (priv.ended) { 68 | return new Promise((resolve, reject) => { 69 | reject(new Error('Worker is already ended')); 70 | }); 71 | } 72 | 73 | let id = count++; 74 | let args = Array.from(arguments); 75 | let acks = priv.acks; 76 | let child = priv.child; 77 | 78 | child.send({ args, method, id: id }); 79 | 80 | return new Promise((resolve, reject) => { 81 | acks.once(id, ack => { 82 | if (ack.error) { 83 | let err = new Error; 84 | for (let i in ack.error) { 85 | err[i] = ack.error[i]; 86 | } 87 | reject(err); 88 | } else if (ack.rejection) { 89 | reject(ack.val); 90 | } else { 91 | resolve(ack.val); 92 | } 93 | }); 94 | }); 95 | }; 96 | } 97 | }); 98 | 99 | return Worker; 100 | } 101 | 102 | if (!module.parent) { 103 | // this is executed when running standalone 104 | let obj; 105 | 106 | process.on('message', msg => { 107 | let ret; 108 | 109 | if (msg.construct) { 110 | // TODO: dont rely on dynamic requires and 111 | // subsitute with `import` and perhaps 112 | // a compilation step 113 | let mod = require(msg.path).default; 114 | obj = new mod(...msg.construct); 115 | } 116 | 117 | if (msg.method) { 118 | if (!obj) throw new Error('Not built'); 119 | ret = obj[msg.method](...msg.args); 120 | } 121 | 122 | if (null != msg.id) { 123 | if (ret instanceof Promise) { 124 | ret.then(val => { 125 | process.send({ id: msg.id, val: val }); 126 | }); 127 | ret.catch(err => { 128 | if (err instanceof Error) { 129 | let obj = { 130 | message: err.message, 131 | stack: err.stack 132 | }; 133 | for (let i in err) obj[i] = err[i]; 134 | process.send({ id: msg.id, error: obj }); 135 | } else { 136 | process.send({ id: msg.id, rejection: err }); 137 | } 138 | }); 139 | } else { 140 | process.send({ id: msg.id, val: ret }); 141 | } 142 | } 143 | }); 144 | } 145 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | 2 | import toWorker from './to-worker'; 3 | 4 | export default toWorker(require.resolve('./process')); 5 | -------------------------------------------------------------------------------- /node/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clif", 3 | "version": "0.0.6", 4 | "description": "", 5 | "dependencies": { 6 | "babel": "6.1.18", 7 | "babel-cli": "6.1.18", 8 | "babel-polyfill": "6.1.19", 9 | "babel-preset-es2015": "6.1.18", 10 | "browserify-middleware": "4.1.0", 11 | "child_pty": "3.0.0", 12 | "clone": "1.0.0", 13 | "commander": "2.6.0", 14 | "express": "4.11.2", 15 | "keypress": "0.2.1", 16 | "neuquant": "^1.0.2", 17 | "omggif": "1.0.5", 18 | "osenv": "0.1.0", 19 | "phantomjs": "1.9.15", 20 | "pngjs": "0.4.0", 21 | "queue3": "1.0.3", 22 | "term.js": "0.0.4", 23 | "uid2": "0.0.3", 24 | "ws": "0.8.0" 25 | }, 26 | "bin": { 27 | "clif": "./bin/clif" 28 | }, 29 | "scripts": { 30 | "postinstall": "babel lib --out-dir node" 31 | }, 32 | "main": "./node/index" 33 | } 34 | --------------------------------------------------------------------------------