├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── index.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── gl.ts ├── index.ts └── shared.ts ├── tests ├── gl.spec.ts ├── helpers.ts ├── index.spec.ts ├── runner.html └── tests.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist 2 | tsconfig.json 3 | src 4 | tests 5 | karma.conf.js 6 | .gitignore 7 | LICENSE 8 | tslint.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mathias Paumgarten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Canvas Recorder 2 | 3 | [![Build Status](https://travis-ci.org/MathiasPaumgarten/canvas-recorder.svg?branch=master)](https://travis-ci.org/MathiasPaumgarten/canvas-recorder) 4 | 5 | > A blatant ripoff of [Looper](https://github.com/spite/looper) 😎 6 | 7 | This is a small utility to record a canvas based animation of any sort. The tool can be used to 8 | run the animation in the browser until one is ready to record it. The setup works around four 9 | core methods depicted in the code below: 10 | 11 | ```js 12 | import { options, start, draw, getCanvas } from "canvas-recorder"; 13 | 14 | options( { 15 | size: [ 500, 500 ], 16 | frames: 30 17 | } ); 18 | 19 | draw( ( context, delta ) => { 20 | 21 | // ... Do something here 22 | 23 | } ); 24 | 25 | document.body.appendChild( getCanvas() ); 26 | 27 | start(); 28 | ``` 29 | 30 | Additionally, `canvas-recorder` can also be used as a command line tool to merge the image sequence into 31 | a MP4 file format. [See here](#cli-tool) 32 | 33 | _Note: The package is written in Typescript and ships with types. Use in JS or TS alike._ 34 | 35 | - [Methods](#methods) 36 | * [`options( settings: {} )`](#options--settings------) 37 | * [`draw( ( context, time ) => {} )`](#draw----context--time----------) 38 | * [`start()`](#start--) 39 | * [`stop()`](#stop--) 40 | * [`setup( ( context ) => {} )`](#setup----context----------) 41 | * [`cleanup( () => {} )`](#cleanup------------) 42 | * [`getCanvas(): HTMLCanvasElement`](#getcanvas----htmlcanvaselement) 43 | * [`getContext(): CanvasRenderingContext2D`](#getcontext----canvasrenderingcontext2d) 44 | * [`bootstrap( options?: {} )`](#bootstrap--) 45 | * [`Recorder`](#recorder) 46 | * [`addFrame( canvas: HTMLCanvasElement ): Promise`](#addframe--canvas--htmlcanvaselement----promise) 47 | * [`getBundle(): JSZip`](#getbundle----jszip) 48 | * [`downloadBundle(): Promise`](#downloadbundle----promise) 49 | - [WebGL](#webgl) 50 | * [Context](#context) 51 | - [Cli Tool](#cli-tool) 52 | * [Flags](#flags) 53 | * [Setup](#setup) 54 | 55 | 56 | ## Methods 57 | 58 | ### `options( settings: {} )` 59 | Used to set settings for the recording. In most cases calling options will be done before any frames are recorded as a 60 | first step of the program. Calling options while in between `start()` and `stop()` (while it is recording) calls is not 61 | permitted. 62 | 63 | It takes one argument which is an object with the following possible settings: 64 | 65 | - `record`: [Default: `true`] Enables/Disables the recording of all frames. Setting this to `false` is useful in 66 | development. Not recording any frames significantly speeds up the drawing cycles. 67 | - `clear`: [Default: `false`] Clears the previous frame on every draw call., 68 | - `size`: [Default: `[1024, 1024]`] Sets the size of the canvas. 69 | - `frames`: [Default: `-1`] Determines the amount of frames recorded. If set to `-1` it will continue recording until 70 | a call to `stop()`. 71 | - `onComplete`: [Default ``] Function that is called when all frames are recorded and archived into a zip in 72 | form of a `Blob`. When not set, a download is triggered automatically. 73 | - `color`: [Default: `"white"`] Sets the background color of every frame if `clear` is set to `true`. 74 | - `fps`: [Default: `60`] The framerate from which the elapsed time is calculated in record mode. Note that the 75 | recording won't happen in at this pace as it is no longer realtime. 76 | - `canvas`: [Default: `HTMLCanvasElement`] Allows to use a specific canvas element for recording instead of creating 77 | an internal one. 78 | 79 | ### `draw( ( context, time ) => {} )` 80 | The draw method is the heart of the recorder. It takes on argument which is a callback. This callback will recieve two 81 | arguments at every invocation: 82 | - `context` which is a `CanvasRenderingContext2D` associated with the Canvas. This context is generally used to draw 83 | the frame. 84 | - `time` is the amount of milliseconds since the most recent `start` call. Using this `time` argument allows for the 85 | async recording to adhere to the animations fluidity. Do not calculate the time yourself, as the recording process is 86 | much slower than the desired framerate. 87 | 88 | ### `start()` 89 | Calling this will start the loop of the recorder. 90 | 91 | ### `stop()` 92 | Will terminate the loop. If the settings are set to `record: true`, calling `stop` will subsequently finalize all 93 | recorded frames and compress them in a ZIP archive. By default this ZIP will trigger a download to save all frames, 94 | unless `onComplete` is set with a costum function. If so, said function will recieve the ZIP in form of a `Blob`. 95 | 96 | ### `setup( ( context ) => {} )` 97 | This method will be called right before the frist draw call. The context is passed. This is especially useful in the 98 | WebGL implementation. 99 | 100 | ### `cleanup( () => {} )` 101 | This is a utility that can be used as a callback after the recording has terminated. This is especially useful when the 102 | recorder is used in frame mode. After the desired amount of frames this method will be called. Once this method is 103 | called all resources can be used freely and won't no longer be used by the recorder. 104 | 105 | ### `getCanvas(): HTMLCanvasElement` 106 | Returns the canvas being used by the recorder. 107 | 108 | ### `getContext(): CanvasRenderingContext2D` 109 | Returns the context attached to the canvas of the recorder. 110 | 111 | ### `bootstrap( options? )` 112 | Shorthand for inserting the canvas into the DOM as well as calling `start()`. This is particularly useful 113 | for short sketches. 114 | 115 | Bootstrapping allows an options paramter. An object that has a single flag `clear`. Calling 116 | `boostrap( { clear: true } )` will terminate previous sketch and remove the previous canvas from 117 | the DOM. This is helpful when one has an auto-reload with a undefined loop. By default `bootstrap()` 118 | does not clear. 119 | 120 | ### `Recorder` 121 | All methods are simply a shorthand for an instance of a `Recorder`. If one would rather instantiate the recorder 122 | themselves, maybe to run multiple recorders at once, do it like so: 123 | 124 | ```ts 125 | import { Recorder } from "canvas-recorder"; 126 | 127 | const recorder = new Recorder(); 128 | 129 | recorder.options( { 130 | ... 131 | } ); 132 | 133 | recorder.draw( ( context: CanvasRenderingContext2D, time: number ) => { 134 | ... 135 | } ); 136 | 137 | recorder.start(); 138 | ``` 139 | 140 | ### `addFrame( canvas: HTMLCanvasElement ): Promise` 141 | In order to use the frame packaging without any of the utility methods listed above, one can use `addFrame` and the 142 | following methods to aggregate all frames manually. This me adds an PNG of the current frame to the bundle. 143 | One can add as many frames as one likes. Use the following methods to retrive the ZIP or download it. 144 | 145 | ### `getBundle(): JSZip` 146 | Returns the current bundle as a zip containing all frames. See [JSZip Documentation](https://stuk.github.io/jszip/) 147 | for how to use it. 148 | 149 | ### `downloadBundle(): Promise` 150 | Downloads the current set of frames and resets the bundle. This is useful if you want to download lots of frames 151 | and don't want the zip to get too large. After calling download the next call to `addFrame` will be in a new bundle. 152 | 153 | 154 | The following example shows how to use it without all helper methods. 155 | 156 | ```typescript 157 | import { addFrame, downloadBundle } from "canvas-recorder"; 158 | 159 | // ... canvas setup 160 | 161 | context.fillStyle = "green"; 162 | context.fillRect( 10, 14, 200, 300 ); 163 | 164 | addFrame( canvas ).then( () => { 165 | downloadBundle(); 166 | } ); 167 | ``` 168 | 169 | ## WebGL 170 | 171 | The package is also avialble with webgl support. The API is quasi identical. In order to use it as a WebGL package 172 | change the import slightly 173 | 174 | ```typescript 175 | import triangle from "a-big-triangle"; 176 | import createShader from "gl-shader"; 177 | import { options, start, draw, getCanvas, setup } from "canvas-recorder/gl"; 178 | 179 | let shader; 180 | 181 | options( { 182 | frames: 10, 183 | size: [ 100, 100 ] 184 | } ); 185 | 186 | setup( ( gl: WebGLRenderingContext ) => { 187 | shader = createShader( 188 | gl, 189 | ` 190 | precision mediump float; 191 | attribute vec2 position; 192 | 193 | varying vec2 uv; 194 | 195 | void main() { 196 | uv = position.xy; 197 | gl_Position = vec4( position.xy, 0.0, 1.0 ); 198 | } 199 | `, 200 | ` 201 | precision mediump float; 202 | varying vec2 uv; 203 | void main() { 204 | gl_FragColor = vec4( 1, 0, 0, 1 ); 205 | } 206 | `, 207 | ); 208 | } ); 209 | 210 | draw( ( gl: WebGLRenderingContext ) => { 211 | 212 | shader.bind(); 213 | 214 | triangle( gl ); 215 | } ); 216 | 217 | start(); 218 | ``` 219 | 220 | ### Context 221 | In this implementation, the context is always a `WebGLRenderingContext` instead of a `CanvasRenderingContext2D`. 222 | 223 | 224 | ## Cli Tool 225 | 226 | Tool to turn the image sequence into a movei format. 227 | 228 | When installed globally, or through the use of a package.json, one can invoke the command `canvas-recorder` or 229 | alternatively use the alias `ffmpy` (pronounced: _effeffempey_) as a shorter command. 230 | 231 | Unsurprisingly uses FFmpeg under the hood. It has a limited amount of possible options but sets defaults for all 232 | of them. Therefore the easiest usecase is calling the command in the directory of the image sequence with not flags 233 | 234 | ### Flags 235 | - `-i, --input ` Path to the folder of the image sequence. Defaults to `.`. 236 | - `-r, --fps ` Framerate used in the movie file. Defaults to `30`. 237 | - `-o, --output ` File name of the output. Defaults to `out.mp4`. 238 | 239 | 240 | ### Setup 241 | When installed globally, the commands are available everywhere. Alternatively, when installed locally in 242 | the project it can still be executed from the package.json 243 | 244 | ```json 245 | "scripts": { 246 | "merge": "canvas-recorder -i ./image-sequence/ -o film.mp4" 247 | } 248 | ``` 249 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | const program = require( "commander" ); 3 | const path = require( "path" ); 4 | const { exec, spawn } = require( "child_process" ); 5 | 6 | program 7 | .version( "1.0.0" ) 8 | .usage( "[options]" ) 9 | .option( "-i, --input ", "Directory containing the image sequence." ) 10 | .option( "-o, --output ", "Name of the output file. Defaults to out.mp4." ) 11 | .option( "-r, --fps ", "The framerate at which the sequence should play. Default is 30." ) 12 | .option( "-k, --format ", "The file format generated: mp4 or gif. Default is mp4" ) 13 | .parse( process.argv ); 14 | 15 | 16 | /** 17 | * Returns the value of a given flag, and falls back to the a given default if flag is not 18 | * specified. An optional array of white listed values can restrict the value 19 | * 20 | * @param {string} name Value of the flag 21 | * @param {string} defaultValue Default value to be returned if flag not found 22 | * @param {string[]=} restrictions Optional. Array of allowed values 23 | * 24 | * @throws If restrictions are given and the value is not listed, the method throws and error 25 | */ 26 | function valueOrDefault( name, defaultValue, restrictions ) { 27 | const value = program[ name ]; 28 | 29 | if ( !value ) { 30 | return defaultValue; 31 | } 32 | 33 | if ( restrictions && restrictions.indexOf( value ) < 0 ) { 34 | throw new Error( name + " has to be one of: " + restrictions.join ( ", " ) ); 35 | } 36 | 37 | return value; 38 | } 39 | 40 | /** 41 | * Creates FFMPEG command based on input options. 42 | * 43 | * @param {Object} options Settings to determine the 44 | * @param {string} options.format Output format either "mp4" or "gif"; 45 | * @param {number} options.fps The frames per second used for both input as well as output 46 | * @param {number} options.output Name of the file used as output. 47 | * 48 | * @returns {string[]} Output command as an array of strings. 49 | */ 50 | function getCommand( options ) { 51 | switch ( options.format ) { 52 | case "mp4": 53 | return [ 54 | `-framerate ${ options.fps }`, 55 | "-i %06d.png", 56 | "-c:v libx264", 57 | "-pix_fmt yuv420p", 58 | options.output, 59 | ]; 60 | case "gif": 61 | return [ 62 | "-f image2", 63 | `-framerate ${ options.fps }`, 64 | "-i %06d.png", 65 | `-r ${ options.fps }`, 66 | options.output, 67 | ]; 68 | default: 69 | return []; 70 | } 71 | } 72 | 73 | exec( "ffmpeg -version", function( error, _, stderr ) { 74 | if ( error || stderr ) { 75 | console.warn( 76 | "Error: canvas-recorder requires ffmpeg to be installed and accessable view `ffmpeg` command. " + 77 | "See: https://ffmpeg.org/" 78 | ); 79 | 80 | return; 81 | } 82 | 83 | const options = {}; 84 | 85 | try { 86 | 87 | options.format = valueOrDefault( "format", "mp4", [ "mp4", "gif" ] ); 88 | options.fps = valueOrDefault( "fps", 30 ); 89 | options.output = valueOrDefault( "output", "out." + options.format ); 90 | options.input = valueOrDefault( "input", "." ); 91 | 92 | } catch( error ) { 93 | return console.error( error.message ); 94 | } 95 | 96 | const spawnOptions = { 97 | stdio: "inherit", 98 | shell: true, 99 | cwd: options.input, 100 | } 101 | 102 | const spawnFlags = getCommand( options ); 103 | 104 | if ( !spawnFlags ) { 105 | console.error( "Something went wrong. canvas-recorder didn't understand the command." ); 106 | return; 107 | } 108 | 109 | spawn( "ffmpeg", spawnFlags, spawnOptions ); 110 | } ); 111 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function( config ) { 2 | config.set( { 3 | basePath: "", 4 | frameworks: [ "mocha", "expect" ], 5 | files: [ 6 | "dist/tests/bundle.js" 7 | ], 8 | exclude: [], 9 | preprocessors: {}, 10 | reporters: [ "mocha" ], 11 | port: 9876, 12 | colors: true, 13 | logLevel: config.LOG_INFO, 14 | autoWatch: false, 15 | browsers: [ "ChromeHeadlessNoSandbox" ], 16 | singleRun: true, 17 | concurrency: Infinity, 18 | customLaunchers: { 19 | ChromeHeadlessNoSandbox: { 20 | base: "ChromeHeadless", 21 | flags: [ "--no-sandbox" ] 22 | } 23 | } 24 | } ); 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-recorder", 3 | "version": "1.8.0", 4 | "description": "Browserside tool to record canvas animations frame by frame.", 5 | "types": "./", 6 | "main": "./", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/MathiasPaumgarten/canvas-recorder" 10 | }, 11 | "scripts": { 12 | "build": "tsc --p tsconfig.json", 13 | "test-files": "npm run build && browserify dist/tests/tests.js > dist/tests/bundle.js", 14 | "test": "npm run test-files && karma start karma.conf.js", 15 | "push": "npm run build && cp ./dist/src/* ./ && npm publish && npm run clean", 16 | "clean": "rm gl.d.ts gl.js index.d.ts index.js shared.d.ts shared.js" 17 | }, 18 | "bin": { 19 | "canvas-recorder": "bin/index.js", 20 | "ffmpy": "bin/index.js" 21 | }, 22 | "keywords": [ 23 | "animation", 24 | "canvas", 25 | "record", 26 | "sequence", 27 | "webgl" 28 | ], 29 | "author": "Mathias Paumgarten", 30 | "license": "MIT", 31 | "dependencies": { 32 | "commander": "^2.20.0", 33 | "file-saver": "^2.0.2", 34 | "jszip": "^3.2.1", 35 | "lodash": "^4.17.11" 36 | }, 37 | "devDependencies": { 38 | "@types/a-big-triangle": "^1.0.0", 39 | "@types/expect.js": "^0.3.29", 40 | "@types/file-saver": "^2.0.1", 41 | "@types/gl-shader": "^4.2.0", 42 | "@types/jszip": "^3.1.6", 43 | "@types/lodash": "^4.14.135", 44 | "@types/mocha": "^5.2.7", 45 | "a-big-triangle": "^1.0.3", 46 | "browserify": "^16.2.3", 47 | "expect.js": "^0.3.1", 48 | "gl-shader": "^4.2.1", 49 | "karma": "^4.1.0", 50 | "karma-chrome-launcher": "^2.2.0", 51 | "karma-cli": "^2.0.0", 52 | "karma-expect": "^1.1.3", 53 | "karma-mocha": "^1.3.0", 54 | "karma-mocha-reporter": "^2.2.5", 55 | "karma-phantomjs-launcher": "^1.0.4", 56 | "mocha": "^6.1.4", 57 | "tslint": "^5.18.0", 58 | "typescript": "^3.5.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/gl.ts: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | import { bindAll } from "lodash"; 3 | 4 | import { BaseRecorder, colorToRGBA, Settings } from "./shared"; 5 | 6 | export class Recorder extends BaseRecorder { 7 | 8 | get gl(): WebGLRenderingContext { 9 | return this.context; 10 | } 11 | 12 | constructor() { 13 | const canvas = document.createElement( "canvas" ); 14 | const context: WebGLRenderingContext = ( 15 | canvas.getContext( "webgl" ) || 16 | canvas.getContext( "experimental-webgl" ) 17 | )! as WebGLRenderingContext; 18 | 19 | super( canvas, context ); 20 | } 21 | 22 | public options( opts: Partial ) { 23 | super.options( opts ); 24 | 25 | const [ r, g, b, a ] = colorToRGBA( this.settings.color ); 26 | this.gl.clearColor( r, g, b, a ); 27 | } 28 | 29 | protected clear() { 30 | this.gl.clear( this.gl.COLOR_BUFFER_BIT ); 31 | } 32 | 33 | protected updateCanvas( canvas: HTMLCanvasElement ) { 34 | this.canvas = canvas; 35 | this.context = ( 36 | canvas.getContext( "webgl" ) || 37 | canvas.getContext( "experimental-webgl" ) 38 | )! as WebGLRenderingContext; 39 | } 40 | } 41 | 42 | export const recorder = new Recorder(); 43 | 44 | bindAll( recorder, [ 45 | "getCanvas", 46 | "getContext", 47 | "options", 48 | "start", 49 | "stop", 50 | "cleanup", 51 | "reset", 52 | "draw", 53 | "setup", 54 | "bootstrap", 55 | "addFrame", 56 | "resetBundle", 57 | "downloadBundle", 58 | "getBundle", 59 | ] ); 60 | 61 | const getCanvas = recorder.getCanvas; 62 | const getContext = recorder.getContext; 63 | const options = recorder.options; 64 | const start = recorder.start; 65 | const stop = recorder.stop; 66 | const cleanup = recorder.cleanup; 67 | const reset = recorder.reset; 68 | const draw = recorder.draw; 69 | const setup = recorder.setup; 70 | const bootstrap = recorder.bootstrap; 71 | const addFrame = recorder.addFrame; 72 | const resetBundle = recorder.resetBundle; 73 | const downloadBundle = recorder.downloadBundle; 74 | const getBundle = recorder.getBundle; 75 | 76 | export default recorder; 77 | export { 78 | getCanvas, 79 | getContext, 80 | options, 81 | start, 82 | stop, 83 | cleanup, 84 | reset, 85 | draw, 86 | setup, 87 | bootstrap, 88 | addFrame, 89 | resetBundle, 90 | downloadBundle, 91 | getBundle, 92 | JSZip, 93 | }; 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | import { bindAll } from "lodash"; 3 | 4 | import { BaseRecorder, DrawOptions, Settings } from "./shared"; 5 | 6 | export class Recorder extends BaseRecorder { 7 | constructor() { 8 | const canvas = document.createElement( "canvas" ); 9 | const context = canvas.getContext( "2d" )!; 10 | 11 | super( canvas, context ); 12 | } 13 | 14 | protected clear() { 15 | this.context.fillStyle = this.settings.color; 16 | this.context.fillRect( 0, 0, this.settings.size[ 0 ], this.settings.size[ 1 ] ); 17 | } 18 | 19 | protected updateCanvas( canvas: HTMLCanvasElement ) { 20 | this.canvas = canvas; 21 | this.context = canvas.getContext( "2d" )!; 22 | } 23 | } 24 | 25 | // For ease of use we make a bound version of the recorder available. 26 | const recorder = new Recorder(); 27 | 28 | bindAll( recorder, [ 29 | "getCanvas", 30 | "getContext", 31 | "options", 32 | "start", 33 | "stop", 34 | "cleanup", 35 | "reset", 36 | "draw", 37 | "bootstrap", 38 | "setup", 39 | "addFrame", 40 | "resetBundle", 41 | "downloadBundle", 42 | "getBundle", 43 | ] ); 44 | 45 | const getCanvas = recorder.getCanvas; 46 | const getContext = recorder.getContext; 47 | const options = recorder.options; 48 | const start = recorder.start; 49 | const stop = recorder.stop; 50 | const cleanup = recorder.cleanup; 51 | const reset = recorder.reset; 52 | const draw = recorder.draw; 53 | const bootstrap = recorder.bootstrap; 54 | const setup = recorder.setup; 55 | const addFrame = recorder.addFrame; 56 | const resetBundle = recorder.resetBundle; 57 | const downloadBundle = recorder.downloadBundle; 58 | const getBundle = recorder.getBundle; 59 | 60 | export default recorder; 61 | export { 62 | getCanvas, 63 | getContext, 64 | options, 65 | start, 66 | stop, 67 | cleanup, 68 | reset, 69 | draw, 70 | bootstrap, 71 | setup, 72 | addFrame, 73 | resetBundle, 74 | downloadBundle, 75 | getBundle, 76 | JSZip, 77 | Settings, 78 | DrawOptions, 79 | }; 80 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from "file-saver"; 2 | import JSZip from "jszip"; 3 | import { assign, bindAll, memoize, padStart } from "lodash"; 4 | 5 | /** 6 | * Complete set of options. 7 | */ 8 | export interface Settings { 9 | record: boolean; 10 | clear: boolean; 11 | size: [ number, number ]; 12 | frames: number; 13 | onComplete: (blob: Blob) => void; 14 | color: string; 15 | fps: number; 16 | canvas?: HTMLCanvasElement; 17 | } 18 | 19 | /** 20 | * Interface to descrive the possible settings when set in an `options()` call. 21 | */ 22 | export type DrawOptions = Partial; 23 | 24 | /** 25 | * Possible options to be sent to bootstrapping a sketch. 26 | */ 27 | export interface BoostrapOptions { 28 | clear: boolean; 29 | } 30 | 31 | /** 32 | * Abstract base class that implements shared internal functionality to record a canvas was animation frame by frame. 33 | */ 34 | export abstract class BaseRecorder { 35 | protected callback?: ( context: T, time: number, percent: number ) => void; 36 | protected teardown?: () => void; 37 | protected before?: ( context: T ) => void; 38 | protected count = 0; 39 | protected startTime = 0; 40 | protected raf: number = 0; 41 | protected isLooping = false; 42 | protected zip = new JSZip(); 43 | 44 | /** 45 | * State of settings currently in use. For more detail please refer to the README. 46 | */ 47 | protected readonly settings: Settings = { 48 | record: true, 49 | clear: false, 50 | size: [ 1024, 1024 ], 51 | frames: -1, 52 | onComplete: download, 53 | color: "white", 54 | fps: 60, 55 | }; 56 | 57 | constructor( protected canvas: HTMLCanvasElement, protected context: T ) { 58 | bindAll( this, [ "loop" ] ); 59 | } 60 | 61 | /** 62 | * Sets specific details on how the recorder functions. 63 | * 64 | * @param opts settings for recording behavior 65 | */ 66 | public options( opts: DrawOptions ) { 67 | if ( this.isLooping ) { 68 | throw new Error( "Options can not be set while animation is in progress." ); 69 | } 70 | 71 | if ( opts.canvas ) { 72 | this.updateCanvas( opts.canvas ); 73 | } 74 | 75 | assign( this.settings, opts ); 76 | this.init(); 77 | } 78 | 79 | /** 80 | * Starts a recording. This will create an asyncronous loop that continously calls the draw function until the 81 | * recorder is either manually stopped or automatically terminated after reaching and amount of desired frames. 82 | * 83 | * Once the recording was started, options can not be changed. 84 | */ 85 | public start() { 86 | if (!this.callback) { 87 | throw new Error( "A drawing routine has to be provided using `draw( ( context, delta ) => void )`." ); 88 | } 89 | 90 | this.init(); 91 | 92 | this.isLooping = true; 93 | this.startTime = Date.now(); 94 | 95 | if ( this.before ) this.before( this.context ); 96 | this.loop(); 97 | } 98 | 99 | /** 100 | * Stops the recording process. When a desired amount of frames is set, this method is not needed as it is 101 | * invoked internally. 102 | * Stopping the recording also starts an asyncrounous routine to finalize the zip file that contains all images. 103 | * Due to the inherent asyncrounous nature of the process, the stop function has no return value. To recieve the 104 | * archive instead of downloading it, set the onComplete method in the option. 105 | */ 106 | public stop() { 107 | this.isLooping = false; 108 | cancelAnimationFrame( this.raf ); 109 | 110 | if ( this.teardown ) this.teardown(); 111 | 112 | if ( this.settings.record && this.zip && this.count > 0 ) { 113 | this.zip.generateAsync( { type: "blob" } ).then( this.settings.onComplete ); 114 | } 115 | } 116 | 117 | /** 118 | * Resets the recorder options to it's originaly state. Terminates any recording if in process. Disposes all 119 | * currently recorded frames. 120 | */ 121 | public reset() { 122 | this.isLooping = false; 123 | cancelAnimationFrame( this.raf ); 124 | 125 | this.settings.record = true; 126 | this.settings.clear = false; 127 | this.settings.size = [ 1024, 1024 ]; 128 | this.settings.frames = -1; 129 | this.settings.onComplete = download; 130 | this.settings.color = "white"; 131 | 132 | this.callback = undefined; 133 | this.teardown = undefined; 134 | this.before = undefined; 135 | 136 | this.init(); 137 | } 138 | 139 | /** 140 | * Sets the callback used to capture a frame. Repeatedly setting callbacks will overwrite the previous one. 141 | * Multiple callbacks can not be set. The callback given will recieve the context and the time passed since the 142 | * start of the recording. Be advised that the time does not represent real time but is dependend on the desired 143 | * amount of FPD set in the options. 144 | * 145 | * @param action callback to capture the animation. 146 | */ 147 | public draw( action: ( context: T, time: number, t: number ) => void ) { 148 | this.callback = action; 149 | } 150 | 151 | /** 152 | * Optinall callback after the animation has terminated. Use this if you need to run some cleanup code after all the 153 | * animating is done. 154 | * @param action callback 155 | */ 156 | public cleanup( action: () => void ) { 157 | this.teardown = action; 158 | } 159 | 160 | public setup( action: ( context: T ) => void ) { 161 | this.before = action; 162 | } 163 | 164 | /** 165 | * Returns the canvas html element in use. Useful to inject it into the DOM when in development mode. 166 | */ 167 | public getCanvas(): HTMLCanvasElement { 168 | return this.canvas; 169 | } 170 | 171 | /** 172 | * Returns the context being used. The type depends on the non-abstract implementation of this class. 173 | */ 174 | public getContext(): T { 175 | return this.context; 176 | } 177 | 178 | /** 179 | * Shorthand to both insert the canvas into the DOM as the last element of the body as 180 | * well as calling `start()`. Useful for short demos that require no further setup. 181 | */ 182 | public bootstrap( options: BoostrapOptions = { clear: false } ) { 183 | if ( options.clear ) { 184 | this.stop(); 185 | document.body.innerHTML = ""; 186 | } 187 | 188 | document.body.appendChild( this.getCanvas() ); 189 | this.start(); 190 | } 191 | 192 | /** 193 | * Adds a single frame to the current bundle. 194 | */ 195 | public addFrame( canvas: HTMLCanvasElement ): Promise { 196 | return record( canvas, this.count++, this.zip ); 197 | } 198 | 199 | /** 200 | * Returns a reference to the current collection of all frames in the form of a JSZip. 201 | */ 202 | public getBundle(): JSZip { 203 | return this.zip; 204 | } 205 | 206 | /** 207 | * Empties all frames and resets the frame count to 0. Leaves all other settings in tact. 208 | * For a more comprehensive reset use the `reset()` method as it reestablishes the default 209 | * mode of the recorder. 210 | */ 211 | public resetBundle() { 212 | this.count = 0; 213 | this.zip = new JSZip(); 214 | } 215 | 216 | /** 217 | * Download the current bundle of frames. This will also reset the bundle to a new empty one. 218 | * All further frames will not be included but can be downloaded subsequently. 219 | */ 220 | public downloadBundle(): Promise { 221 | return this.zip.generateAsync( { type: "blob" } ) 222 | .then( download ) 223 | .then( () => { 224 | this.count = 0; 225 | this.zip = new JSZip(); 226 | } ); 227 | } 228 | 229 | protected abstract clear(): void; 230 | 231 | protected abstract updateCanvas( canvas: HTMLCanvasElement ): void; 232 | 233 | private init() { 234 | this.canvas.width = this.settings.size[ 0 ]; 235 | this.canvas.height = this.settings.size[ 1 ]; 236 | 237 | this.clear(); 238 | 239 | this.zip = new JSZip(); 240 | this.count = 0; 241 | } 242 | 243 | private loop() { 244 | if ( !this.isLooping ) return; 245 | 246 | const delta = this.settings.record ? 247 | this.count * ( 1000 / this.settings.fps ) : 248 | Date.now() - this.startTime; 249 | 250 | const t = this.settings.frames > 0 ? 251 | ( this.count / this.settings.frames ) % 1 : 252 | -1; 253 | 254 | if ( this.settings.clear ) { 255 | this.clear(); 256 | } 257 | 258 | this.callback!( this.context, delta, t ); 259 | this.count++; 260 | 261 | if ( this.settings.record ) { 262 | record( this.canvas, this.count - 1, this.zip! ).then( () => { 263 | if ( this.count >= this.settings.frames && this.settings.frames > 0 ) this.stop(); 264 | else if ( this.isLooping ) this.nextFrame(); 265 | } ); 266 | } else if ( this.settings.frames > 0 && this.count >= this.settings.frames ) { 267 | this.stop(); 268 | } else { 269 | this.nextFrame(); 270 | } 271 | } 272 | 273 | private nextFrame() { 274 | cancelAnimationFrame( this.raf ); 275 | this.raf = requestAnimationFrame( this.loop ); 276 | } 277 | } 278 | 279 | const strictColorToRGBA = memoize( ( color: string ) => { 280 | const canvas = document.createElement( "canvas" ); 281 | const context = canvas.getContext( "2d" )!; 282 | 283 | canvas.width = 1; 284 | canvas.height = 1; 285 | 286 | context.fillStyle = color; 287 | context.fillRect( 0, 0, 1, 1 ); 288 | 289 | const data = context.getImageData( 0, 0, 1, 1 ).data; 290 | 291 | return [ data[ 0 ], data[ 1 ], data[ 2 ], data[ 3 ] ]; 292 | } ); 293 | 294 | /** 295 | * Helper that uses browser internal methods to convert CSS based color string into a RGBA number array. 296 | * The array returned contains 4 numbers ranging from 0 to 1. 297 | * 298 | * @param color Variously formatted color string. 299 | */ 300 | export function colorToRGBA( color: string ): [ number, number, number, number ] { 301 | return strictColorToRGBA( color ) as [ number, number, number, number ]; 302 | } 303 | 304 | export function download( blob: Blob ) { 305 | saveAs( blob, "frames.zip" ); 306 | } 307 | 308 | export function record( canvas: HTMLCanvasElement, frame: number, zip: JSZip ): Promise { 309 | return new Promise( resolve => { 310 | canvas.toBlob( ( blob: Blob | null ) => { 311 | 312 | const name = `${ padStart( frame.toString(), 6, "0" ) }.png`; 313 | zip.file( name, blob!, { base64: true } ); 314 | resolve(); 315 | 316 | }, "image/png" ); 317 | } ); 318 | } 319 | -------------------------------------------------------------------------------- /tests/gl.spec.ts: -------------------------------------------------------------------------------- 1 | import triangle from "a-big-triangle"; 2 | import createShader from "gl-shader"; 3 | import JSZip from "jszip"; 4 | 5 | import { bootstrap, cleanup, draw, getCanvas, getContext, options, reset, setup, start, stop } from "../src/gl"; 6 | import { base64ToImage, imageToCanvas } from "./helpers"; 7 | 8 | export function specs() { 9 | 10 | describe( "canvas-recorder/gl", () => { 11 | 12 | it( "should return a canvas", () => { 13 | expect( getCanvas() instanceof HTMLCanvasElement ).to.be( true ); 14 | } ); 15 | 16 | it( "should return a webgl context", () => { 17 | expect( getContext() instanceof WebGLRenderingContext ).to.be( true ); 18 | expect( getContext().canvas ).to.be( getCanvas() ); 19 | } ); 20 | 21 | describe( "options", () => { 22 | beforeEach( () => { 23 | reset(); 24 | } ); 25 | 26 | it( "should set canvas to correct size", () => { 27 | options( { 28 | size: [ 300, 500 ], 29 | } ); 30 | 31 | const canvas = getCanvas(); 32 | 33 | expect( canvas.width ).to.be( 300 ); 34 | expect( canvas.height ).to.be( 500 ); 35 | } ); 36 | 37 | it( "should call draw 3 times", ( done: MochaDone ) => { 38 | let count = 0; 39 | 40 | options( { 41 | frames: 3, 42 | record: false, 43 | } ); 44 | 45 | draw( () => { 46 | count++; 47 | } ); 48 | 49 | cleanup( () => { 50 | expect( count ).to.be( 3 ); 51 | done(); 52 | } ); 53 | 54 | start(); 55 | } ); 56 | 57 | it( "should throw when changing options while animating", () => { 58 | options( { 59 | record: false, 60 | } ); 61 | 62 | draw( () => {} ); 63 | start(); 64 | 65 | expect( () => options( { color: "green" } ) ).to.throwError(); 66 | 67 | stop(); 68 | } ); 69 | 70 | it( "should fix delta time when recording", ( done: MochaDone ) => { 71 | options( { 72 | fps: 10, 73 | size: [ 8, 8 ], 74 | onComplete: () => {}, 75 | } ); 76 | 77 | let count = 0; 78 | 79 | draw( ( _, delta: number ) => { 80 | expect( delta ).to.be( count * 100 ); 81 | 82 | if ( ++count > 10 ) { 83 | stop(); 84 | done(); 85 | } 86 | } ); 87 | 88 | start(); 89 | } ); 90 | 91 | it( "should set a given canvas", ( done: MochaDone ) => { 92 | const canvas = document.createElement( "canvas" ); 93 | const context = canvas.getContext( "webgl" )! || canvas.getContext( "experimental-webgl" )!; 94 | 95 | expect( getCanvas() ).not.to.be( canvas ); 96 | 97 | options( { 98 | record: false, 99 | size: [ 30, 40 ], 100 | canvas, 101 | } ); 102 | 103 | expect( getCanvas() ).to.be( canvas ); 104 | expect( getContext() ).to.be( context ); 105 | 106 | draw( ( c: WebGLRenderingContext ) => { 107 | expect( c ).to.be( context ); 108 | 109 | done(); 110 | stop(); 111 | } ); 112 | 113 | start(); 114 | 115 | expect( canvas.width ).to.be( 30 ); 116 | expect( canvas.height ).to.be( 40 ); 117 | } ); 118 | 119 | } ); 120 | 121 | describe( "draw", () => { 122 | let count: number; 123 | 124 | beforeEach( () => { 125 | count = 0; 126 | reset(); 127 | } ); 128 | 129 | it( "should pass the t state", ( done: MochaDone ) => { 130 | options( { 131 | frames: 10, 132 | record: false, 133 | } ); 134 | 135 | draw( ( _0, _1, t: number ) => { 136 | expect( t ).to.be( count / 10 ); 137 | count++; 138 | } ); 139 | 140 | cleanup( done ); 141 | 142 | start(); 143 | } ); 144 | 145 | it( "should pass -1 as t state", ( done: MochaDone ) => { 146 | options( { 147 | record: false, 148 | } ); 149 | 150 | draw( ( _0, _1, t: number ) => { 151 | expect( t ).to.be( -1 ); 152 | 153 | if ( ++count > 10 ) { 154 | stop(); 155 | } 156 | } ); 157 | 158 | cleanup( done ); 159 | 160 | start(); 161 | } ); 162 | } ); 163 | 164 | describe( "bootstrap", () => { 165 | beforeEach( () => { 166 | reset(); 167 | 168 | options( { 169 | record: false, 170 | } ); 171 | } ); 172 | 173 | it( "should add canvas to DOM", ( done: MochaDone ) => { 174 | 175 | draw( ( context: WebGLRenderingContext ) => { 176 | expect( context ).to.be( getContext() ); 177 | stop(); 178 | done(); 179 | } ); 180 | 181 | document.body.innerHTML = ""; 182 | bootstrap(); 183 | expect(document.children[ 0 ]).to.be( getCanvas() ); 184 | } ); 185 | 186 | it( "should not throw an error and clear previous call", () => { 187 | draw(() => {}); 188 | 189 | document.body.innerHTML = ""; 190 | bootstrap(); 191 | 192 | expect( document.body.children.length ).to.be( 1 ); 193 | 194 | expect(() => bootstrap( { clear: true } ) ).not.to.throwError(); 195 | expect( document.body.children.length ).to.be( 1 ); 196 | } ); 197 | } ); 198 | 199 | describe( "zip", () => { 200 | 201 | beforeEach( () => { 202 | reset(); 203 | } ); 204 | 205 | it( "should recieve a zip as blob", ( done: MochaDone ) => { 206 | options( { 207 | frames: 2, 208 | onComplete: ( blob: Blob ) => { 209 | expect( blob instanceof Blob ).to.be( true ); 210 | expect( blob.type ).to.be( "application/zip" ); 211 | done(); 212 | }, 213 | } ); 214 | 215 | draw( ( gl: WebGLRenderingContext ) => { 216 | triangle( gl ); 217 | } ); 218 | 219 | start(); 220 | } ); 221 | 222 | it( "should create a red frame", ( done: MochaDone ) => { 223 | 224 | let shader: ReturnType; 225 | 226 | options( { 227 | frames: 1, 228 | size: [ 10, 10 ], 229 | onComplete: ( blob: Blob ) => { 230 | JSZip.loadAsync( blob ) 231 | .then( ( zip: JSZip ) => zip.file( "000000.png" ).async( "base64" ) ) 232 | .then( base64ToImage ) 233 | .then( imageToCanvas ) 234 | .then( ( canvas: HTMLCanvasElement ) => { 235 | expect( canvas.width ).to.be( 10 ); 236 | expect( canvas.height ).to.be( 10 ); 237 | 238 | const context = canvas.getContext( "2d" )!; 239 | const data = context.getImageData( 0, 0, 10, 10 ).data; 240 | 241 | for ( let i = 0; i < data.length; i += 4 ) { 242 | expect( data[ i ] ).to.be( 255 ); 243 | expect( data[ i + 1 ] ).to.be( 0 ); 244 | expect( data[ i + 2 ] ).to.be( 0 ); 245 | expect( data[ i + 3 ] ).to.be( 255 ); 246 | } 247 | 248 | done(); 249 | } ); 250 | }, 251 | } ); 252 | 253 | setup( ( gl: WebGLRenderingContext ) => { 254 | shader = createShader( 255 | gl, 256 | ` 257 | precision mediump float; 258 | attribute vec2 position; 259 | 260 | varying vec2 uv; 261 | 262 | void main() { 263 | uv = position.xy; 264 | gl_Position = vec4( position.xy, 0.0, 1.0 ); 265 | } 266 | `, 267 | ` 268 | precision mediump float; 269 | varying vec2 uv; 270 | void main() { 271 | gl_FragColor = vec4( 1, 0, 0, 1 ); 272 | } 273 | `, 274 | ); 275 | } ); 276 | 277 | draw( ( gl: WebGLRenderingContext ) => { 278 | 279 | shader.bind(); 280 | 281 | triangle( gl ); 282 | } ); 283 | 284 | start(); 285 | } ); 286 | 287 | } ); 288 | 289 | } ); 290 | 291 | } 292 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | export function base64ToImage( content: string ): Promise { 2 | return new Promise( ( resolve, reject ) => { 3 | const image = new Image(); 4 | 5 | image.onerror = reject; 6 | image.onload = () => { 7 | resolve( image ); 8 | }; 9 | 10 | image.src = `data:image/png;base64,${ content }`; 11 | } ); 12 | } 13 | 14 | export function imageToCanvas( image: HTMLImageElement ): HTMLCanvasElement { 15 | const canvas = document.createElement( "canvas" ); 16 | const context = canvas.getContext( "2d" )!; 17 | 18 | canvas.width = image.width; 19 | canvas.height = image.height; 20 | 21 | context.drawImage( image, 0, 0 ); 22 | 23 | return canvas; 24 | } 25 | -------------------------------------------------------------------------------- /tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | 3 | import { 4 | addFrame, 5 | bootstrap, 6 | cleanup, 7 | downloadBundle, 8 | draw, 9 | getBundle, 10 | getCanvas, 11 | getContext, 12 | options, 13 | Recorder, 14 | reset, 15 | resetBundle, 16 | start, 17 | stop, 18 | } from "../src"; 19 | import { base64ToImage } from "./helpers"; 20 | 21 | export function specs() { 22 | 23 | describe( "canvas-recorder", () => { 24 | 25 | it( "should return a canvas element", () => { 26 | expect( getCanvas() instanceof HTMLCanvasElement ).to.be( true ); 27 | } ); 28 | 29 | it( "should return a drawing context", () => { 30 | expect( getContext() instanceof CanvasRenderingContext2D ).to.be( true ); 31 | expect( getContext().canvas ).to.be( getCanvas() ); 32 | expect( getCanvas().getContext( "2d" ) ).to.be( getContext() ); 33 | } ); 34 | 35 | describe( "options", () => { 36 | 37 | beforeEach( () => { 38 | reset(); 39 | } ); 40 | 41 | it( "should set canvas to correct size", () => { 42 | options( { 43 | size: [ 300, 500 ], 44 | } ); 45 | 46 | const canvas = getCanvas(); 47 | 48 | expect( canvas.width ).to.be( 300 ); 49 | expect( canvas.height ).to.be( 500 ); 50 | } ); 51 | 52 | it( "should set the clear color to black", () => { 53 | options( { 54 | color: "black", 55 | clear: true, 56 | record: false, 57 | } ); 58 | 59 | draw( ( context ) => { 60 | const data = context.getImageData( 0, 0, 1, 1 ).data; 61 | 62 | expect( data[ 0 ] ).to.be( 0 ); 63 | expect( data[ 1 ] ).to.be( 0 ); 64 | expect( data[ 2 ] ).to.be( 0 ); 65 | expect( data[ 3 ] ).to.be( 255 ); 66 | 67 | stop(); 68 | } ); 69 | 70 | start(); 71 | } ); 72 | 73 | it( "should clear the previous content", ( done: MochaDone ) => { 74 | 75 | let count = 0; 76 | 77 | options( { 78 | clear: true, 79 | record: false, 80 | } ); 81 | 82 | draw( ( context ) => { 83 | const data = context.getImageData( 0, 0, 1, 1 ).data; 84 | 85 | expect( data[ 0 ] ).to.be( 255 ); 86 | expect( data[ 1 ] ).to.be( 255 ); 87 | expect( data[ 2 ] ).to.be( 255 ); 88 | expect( data[ 3 ] ).to.be( 255 ); 89 | 90 | context.fillStyle = "green"; 91 | context.fillRect( 0, 0, 1, 1 ); 92 | 93 | count++; 94 | 95 | if ( count > 2 ) { 96 | stop(); 97 | done(); 98 | } 99 | } ); 100 | 101 | start(); 102 | } ); 103 | 104 | it( "should not clear the previous canvas", ( done: MochaDone ) => { 105 | let count = 0; 106 | 107 | options( { 108 | clear: false, 109 | record: false, 110 | } ); 111 | 112 | draw( ( context ) => { 113 | const data = context.getImageData( 0, 0, 1, 1 ).data; 114 | 115 | if ( count > 0 ) { 116 | expect( data[ 0 ] ).to.be( 0 ); 117 | expect( data[ 1 ] ).to.be( 255 ); 118 | expect( data[ 2 ] ).to.be( 0 ); 119 | expect( data[ 3 ] ).to.be( 255 ); 120 | } 121 | 122 | context.fillStyle = "rgb( 0, 255, 0 )"; 123 | context.fillRect( 0, 0, 1, 1 ); 124 | 125 | count++; 126 | 127 | if ( count > 2 ) { 128 | stop(); 129 | done(); 130 | } 131 | } ); 132 | 133 | start(); 134 | } ); 135 | 136 | it( "should call draw 3 times", ( done: MochaDone ) => { 137 | let count = 0; 138 | 139 | options( { 140 | frames: 3, 141 | record: false, 142 | } ); 143 | 144 | draw( () => { 145 | count++; 146 | } ); 147 | 148 | cleanup( () => { 149 | expect( count ).to.be( 3 ); 150 | done(); 151 | } ); 152 | 153 | start(); 154 | } ); 155 | 156 | it( "should throw when changing options while animating", () => { 157 | options( { 158 | record: false, 159 | } ); 160 | 161 | draw( () => {} ); 162 | start(); 163 | 164 | expect( () => options( { color: "green" } ) ).to.throwError(); 165 | 166 | stop(); 167 | } ); 168 | 169 | it( "should fix delta time when recording", ( done: MochaDone ) => { 170 | options( { 171 | fps: 10, 172 | onComplete: () => {}, 173 | } ); 174 | 175 | let count = 0; 176 | 177 | draw( ( _ , delta: number ) => { 178 | expect( delta ).to.be( count * 100 ); 179 | 180 | if ( ++count > 10 ) { 181 | stop(); 182 | done(); 183 | } 184 | } ); 185 | 186 | start(); 187 | } ); 188 | 189 | it( "should set a given canvas", ( done: MochaDone ) => { 190 | const canvas = document.createElement( "canvas" ); 191 | const context = canvas.getContext( "2d" )!; 192 | 193 | expect( getCanvas() ).not.to.be( canvas ); 194 | 195 | options( { 196 | record: false, 197 | size: [ 30, 40 ], 198 | canvas, 199 | } ); 200 | 201 | expect( getCanvas() ).to.be( canvas ); 202 | expect( getContext() ).to.be( context ); 203 | 204 | draw( ( c: CanvasRenderingContext2D ) => { 205 | expect( c ).to.be( context ); 206 | 207 | done(); 208 | stop(); 209 | } ); 210 | 211 | start(); 212 | 213 | expect( canvas.width ).to.be( 30 ); 214 | expect( canvas.height ).to.be( 40 ); 215 | } ); 216 | 217 | } ); 218 | 219 | describe( "draw", () => { 220 | let count: number; 221 | 222 | beforeEach( () => { 223 | count = 0; 224 | reset(); 225 | } ); 226 | 227 | it( "should pass the t state", ( done: MochaDone ) => { 228 | options( { 229 | frames: 10, 230 | record: false, 231 | } ); 232 | 233 | draw( ( _0, _1, t: number ) => { 234 | expect( t ).to.be( count / 10 ); 235 | count++; 236 | } ); 237 | 238 | cleanup( done ); 239 | 240 | start(); 241 | } ); 242 | 243 | it( "should pass -1 as t state", ( done: MochaDone ) => { 244 | options( { 245 | record: false, 246 | } ); 247 | 248 | draw( ( _0, _1, t: number ) => { 249 | expect( t ).to.be( -1 ); 250 | 251 | if ( ++count > 10 ) { 252 | stop(); 253 | } 254 | } ); 255 | 256 | cleanup( done ); 257 | 258 | start(); 259 | } ); 260 | } ); 261 | 262 | describe( "bootstrap", () => { 263 | beforeEach( () => { 264 | reset(); 265 | 266 | options( { 267 | record: false, 268 | } ); 269 | } ); 270 | 271 | it( "should add canvas to DOM", ( done: MochaDone ) => { 272 | 273 | draw( ( context: CanvasRenderingContext2D ) => { 274 | expect( context ).to.be( getContext() ); 275 | stop(); 276 | done(); 277 | } ); 278 | 279 | document.body.innerHTML = ""; 280 | bootstrap(); 281 | expect( document.body.children[ 0 ] ).to.be( getCanvas() ); 282 | } ); 283 | 284 | it( "should not throw an error and clear previous call", () => { 285 | draw(() => {}); 286 | 287 | document.body.innerHTML = ""; 288 | bootstrap(); 289 | 290 | expect( document.body.children.length ).to.be( 1 ); 291 | 292 | expect(() => bootstrap( { clear: true } ) ).not.to.throwError(); 293 | expect( document.body.children.length ).to.be( 1 ); 294 | } ); 295 | } ); 296 | 297 | describe( "zip", () => { 298 | 299 | beforeEach( () => { 300 | reset(); 301 | } ); 302 | 303 | it( "should recieve a zip as blob", ( done: MochaDone ) => { 304 | options( { 305 | frames: 2, 306 | onComplete: ( blob: Blob ) => { 307 | expect( blob instanceof Blob ).to.be( true ); 308 | expect( blob.type ).to.be( "application/zip" ); 309 | done(); 310 | }, 311 | } ); 312 | 313 | draw( ( context: CanvasRenderingContext2D ) => { 314 | context.fillStyle = "black"; 315 | context.fillRect( 10, 10, 100, 100 ); 316 | } ); 317 | 318 | start(); 319 | } ); 320 | 321 | it( "should contain 3 images", ( done: MochaDone ) => { 322 | 323 | function verify( blob: Blob ) { 324 | JSZip.loadAsync( blob ) 325 | .then( ( zip: JSZip ) => { 326 | let count = 0; 327 | 328 | zip.forEach( ( path: string ) => { 329 | expect( path ).to.be( `00000${ count++ }.png` ); 330 | } ); 331 | 332 | done(); 333 | } ); 334 | } 335 | 336 | options( { 337 | frames: 3, 338 | onComplete: verify, 339 | } ); 340 | 341 | draw( ( context: CanvasRenderingContext2D ) => { 342 | context.fillStyle = "black"; 343 | context.fillRect( 10, 10, 100, 100 ); 344 | } ); 345 | 346 | start(); 347 | } ); 348 | 349 | it( "should create images in the correct size", ( done: MochaDone ) => { 350 | function verify( blob: Blob ) { 351 | JSZip.loadAsync( blob ) 352 | .then( ( zip: JSZip ) => zip.file( "000000.png" ).async( "base64" ) ) 353 | .then( base64ToImage ) 354 | .then( ( image: HTMLImageElement ) => { 355 | expect( image.width ).to.be( 3 ); 356 | expect( image.height ).to.be( 5 ); 357 | done(); 358 | } ); 359 | } 360 | 361 | options( { 362 | size: [ 3, 5 ], 363 | frames: 1, 364 | onComplete: verify, 365 | } ); 366 | 367 | draw( () => {} ); 368 | 369 | start(); 370 | } ); 371 | 372 | it( "should create uncompressed images", ( done: MochaDone ) => { 373 | 374 | function verify( blob: Blob ) { 375 | JSZip.loadAsync( blob ) 376 | .then( ( zip: JSZip ) => { 377 | return Promise.all( [ 378 | "000000.png", 379 | "000001.png", 380 | "000002.png", 381 | "000003.png", 382 | ].map( name => zip.file( name ).async( "base64" ) ) ); 383 | } ) 384 | .then( ( contents: string[] ) => { 385 | return Promise.all( contents.map( content => base64ToImage( content ) ) ) ; 386 | } ) 387 | .then( ( images: HTMLImageElement[] ) => { 388 | images.forEach( ( image: HTMLImageElement, index: number ) => { 389 | const n = index + 1; 390 | const canvas = document.createElement( "canvas" ); 391 | const context = canvas.getContext( "2d" )!; 392 | 393 | canvas.width = image.width; 394 | canvas.height = image.height; 395 | 396 | context.drawImage( image, 0, 0 ); 397 | 398 | const data = context.getImageData( 0, 0, 4, 1 ).data; 399 | 400 | for ( let x = 0; x < 16; x += 4 ) { 401 | expect( data[ x ] ).to.be( 10 * n ); 402 | expect( data[ x + 1 ] ).to.be( 20 * n ); 403 | expect( data[ x + 2] ).to.be( 30 * n ); 404 | } 405 | } ); 406 | 407 | done(); 408 | } ); 409 | } 410 | 411 | options( { 412 | size: [ 4, 1 ], 413 | frames: 4, 414 | onComplete: verify, 415 | } ); 416 | 417 | let frame = 1; 418 | 419 | draw( ( context: CanvasRenderingContext2D ) => { 420 | 421 | for ( let x = 0; x < 4; x++ ) { 422 | context.fillStyle = `rgb( ${ 10 * frame }, ${ 20 * frame }, ${ 30 * frame } )`; 423 | context.fillRect( x, 0, 1, 1 ); 424 | } 425 | 426 | frame++; 427 | 428 | } ); 429 | 430 | start(); 431 | } ); 432 | 433 | } ); 434 | 435 | describe( "new Recorder()", () => { 436 | 437 | it( "should create a new instance", () => { 438 | const recorder = new Recorder(); 439 | expect( recorder instanceof Recorder ).to.be( true ); 440 | } ); 441 | 442 | it( "should own seperate canvases", () => { 443 | const a = new Recorder(); 444 | const b = new Recorder(); 445 | 446 | a.options( { 447 | record: false, 448 | size: [ 100, 100 ], 449 | } ); 450 | 451 | b.options( { 452 | record: false, 453 | size: [ 30, 40 ], 454 | } ); 455 | 456 | expect( a.getCanvas().width ).to.be( 100 ); 457 | expect( a.getCanvas().height ).to.be( 100 ); 458 | 459 | expect( b.getCanvas().width ).to.be( 30 ); 460 | expect( b.getCanvas().height ).to.be( 40 ); 461 | } ); 462 | 463 | it( "should run simulaniously", ( done: MochaDone ) => { 464 | 465 | const a = new Recorder(); 466 | const b = new Recorder(); 467 | 468 | a.options( { 469 | record: false, 470 | } ); 471 | 472 | b.options( { 473 | record: false, 474 | } ); 475 | 476 | let aDidDraw = false; 477 | let bDidDraw = false; 478 | let checked = 0; 479 | 480 | function check() { 481 | checked++; 482 | 483 | if ( checked === 2 ) { 484 | expect( aDidDraw ).to.be( true ); 485 | expect( bDidDraw ).to.be( true ); 486 | done(); 487 | } 488 | 489 | if ( checked > 2 ) { 490 | throw new Error( "loops should have been stopped" ); 491 | } 492 | } 493 | 494 | a.draw( () => { 495 | aDidDraw = true; 496 | check(); 497 | a.stop(); 498 | } ); 499 | 500 | b.draw( () => { 501 | bDidDraw = true; 502 | check(); 503 | b.stop(); 504 | } ); 505 | 506 | 507 | a.start(); 508 | b.start(); 509 | 510 | } ); 511 | 512 | } ); 513 | 514 | describe( "shorthand", () => { 515 | let canvas: HTMLCanvasElement; 516 | let context: CanvasRenderingContext2D; 517 | 518 | beforeEach( () => { 519 | canvas = document.createElement( "canvas" ); 520 | context = canvas.getContext( "2d" )!; 521 | 522 | canvas.width = 100; 523 | canvas.height = 100; 524 | 525 | resetBundle(); 526 | } ); 527 | 528 | it( "should push multiple frames", ( done: MochaDone ) => { 529 | context.fillStyle = "red"; 530 | context.fillRect( 0, 0, 100, 100 ); 531 | 532 | addFrame( canvas ) 533 | .then( () => { 534 | context.fillStyle = "green"; 535 | context.fillRect( 0, 0, 100, 100 ); 536 | 537 | return addFrame( canvas ); 538 | } ) 539 | .then( () => { 540 | let count = 0; 541 | 542 | getBundle().forEach( () => count++ ); 543 | 544 | expect( count ).to.be( 2 ); 545 | done(); 546 | } ); 547 | } ); 548 | 549 | it( "should reset the bundle", ( done: MochaDone ) => { 550 | context.fillStyle = "red"; 551 | context.fillRect( 0, 0, 100, 100 ); 552 | 553 | addFrame( canvas ) 554 | .then( () => { 555 | context.fillStyle = "green"; 556 | context.fillRect( 0, 0, 100, 100 ); 557 | 558 | return addFrame( canvas ); 559 | } ) 560 | .then( () => { 561 | return downloadBundle(); 562 | } ) 563 | .then( () => addFrame( canvas ) ) 564 | .then( () => { 565 | let count = 0; 566 | 567 | getBundle().forEach( ( path: string ) => { 568 | expect( path ).to.be( "000000.png" ); 569 | count++; 570 | } ); 571 | 572 | expect( count ).to.be( 1 ); 573 | done(); 574 | } ); 575 | } ); 576 | } ); 577 | } ); 578 | } 579 | -------------------------------------------------------------------------------- /tests/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/tests.ts: -------------------------------------------------------------------------------- 1 | import * as gl from "./gl.spec"; 2 | import * as canvas from "./index.spec"; 3 | 4 | canvas.specs(); 5 | gl.specs(); 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2016", 7 | "dom", 8 | ], 9 | "declaration": true, 10 | "outDir": "./dist/", 11 | "strict": true, 12 | "alwaysStrict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "allowSyntheticDefaultImports": true, 16 | "esModuleInterop": true 17 | }, 18 | "include": [ 19 | "src/**/*", 20 | "tests/**/*.ts", 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "max-line-length": { 5 | "options": [ 120 ] 6 | }, 7 | "arrow-parens": false, 8 | "curly": false, 9 | "interface-name": false, 10 | "new-parens": true, 11 | "no-arg": true, 12 | "no-bitwise": false, 13 | "no-conditional-assignment": true, 14 | "no-consecutive-blank-lines": false, 15 | "no-empty-interface": true, 16 | "no-empty": false, 17 | "object-literal-sort-keys": false, 18 | "no-unused-variable": false, 19 | "no-console": { 20 | "severity": "warning", 21 | "options": [ 22 | "debug", 23 | "info", 24 | "log", 25 | "time", 26 | "timeEnd", 27 | "trace" 28 | ] 29 | } 30 | }, 31 | "jsRules": { 32 | "max-line-length": { 33 | "options": [ 120 ] 34 | } 35 | } 36 | } --------------------------------------------------------------------------------