diff --git a/CHANGES.md b/CHANGES.md index 4df8615f..63862cf7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,15 @@ # videojs-record changelog +## x.x.x - unreleased + +- New gifshot plugin: improved animated GIF support (#291) + +**Backwards-incompatible changes** (when upgrading from a previous version): + +- Support for animated GIF recording using RecordRTC was removed and replaced + with a new plugin using the Gifshot library (#291) + + ## 4.2.0 - unreleased - New ffmpeg.wasm converter plugin: convert recorded data into other diff --git a/build-config/fragments/common.js b/build-config/fragments/common.js index d333b501..8d44091d 100644 --- a/build-config/fragments/common.js +++ b/build-config/fragments/common.js @@ -72,6 +72,13 @@ module.exports = { commonjs2: 'recordrtc', amd: 'recordrtc', root: 'RecordRTC' // indicates global variable + }, + // plugins + 'gifshot': { + commonjs: 'gifshot', + commonjs2: 'gifshot', + amd: 'gifshot', + root: 'gifshot' // indicates global variable } }, module: { diff --git a/examples/animated-gif.html b/examples/animated-gif.html index 51314621..9225f018 100644 --- a/examples/animated-gif.html +++ b/examples/animated-gif.html @@ -9,29 +9,30 @@ - - + + - + diff --git a/karma.conf.js b/karma.conf.js index 3fa214bb..5be796a5 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -38,6 +38,7 @@ const chromeFlags = [ '--enable-experimental-web-platform-features', '--js-flags=--max-old-space-size=8196' ]; + //------------------------------------------- // Firefox CLI options //------------------------------------------- @@ -138,8 +139,8 @@ module.exports = function(config) { // ffmpeg.wasm {pattern: 'node_modules/@ffmpeg/ffmpeg/dist/ffmpeg.min.js', included: false, served: true}, {pattern: 'node_modules/@ffmpeg/core/dist/ffmpeg-core.js', included: false, served: true}, - // gif-recorder: only available on CDN - 'http://cdn.webrtc-experiment.com/gif-recorder.js', + // gifshot + 'node_modules/gifshot/dist/gifshot.min.js', // ------------------------------------------- // specs @@ -163,6 +164,7 @@ module.exports = function(config) { // do not include tests or libraries 'src/js/**/*.js': ['coverage'] }, + webpack: webpackConfig, webpackMiddleware: { stats: 'errors-only' }, @@ -245,7 +247,6 @@ module.exports = function(config) { { type: 'lcov', subdir: 'lcov' } ] }, - webpack: webpackConfig, customLaunchers: { Chrome_dev: { base: 'Chrome', diff --git a/package-lock.json b/package-lock.json index 4f5b51ce..8c5e9f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7006,6 +7006,12 @@ "assert-plus": "^1.0.0" } }, + "gifshot": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/gifshot/-/gifshot-0.4.5.tgz", + "integrity": "sha1-48tXAgOjsTn/MGnXV4CYopwDsPg=", + "dev": true + }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", diff --git a/package.json b/package.json index 2766498f..6d8e9158 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "file-loader": "^6.2.0", "fine-uploader": ">=5.16", "formidable": "^1.2.2", + "gifshot": ">=0.4.5", "fs-extra": "^9.1.0", "host-environment": "^2.1.2", "htmlhint": "^0.14.2", diff --git a/src/js/controls/animation-display.js b/src/js/controls/animation-display.js index 92dfd8c2..d9e89917 100644 --- a/src/js/controls/animation-display.js +++ b/src/js/controls/animation-display.js @@ -8,7 +8,7 @@ import videojs from 'video.js'; const Component = videojs.getComponent('Component'); /** - * Image for displaying animated GIF image. + * Component for displaying animated GIF images. * * @class * @augments videojs.Component @@ -27,6 +27,17 @@ class AnimationDisplay extends Component { innerHTML: '' }); } + + /** + * Set `src` of image element. + * + * @param {object} data - TODO + */ + load(data) { + let img = this.el().firstChild; + + img.src = data; + } } Component.registerComponent('AnimationDisplay', AnimationDisplay); diff --git a/src/js/controls/record-canvas.js b/src/js/controls/record-canvas.js index 0e33345d..ecf2aecd 100644 --- a/src/js/controls/record-canvas.js +++ b/src/js/controls/record-canvas.js @@ -8,7 +8,7 @@ import videojs from 'video.js'; const Component = videojs.getComponent('Component'); /** - * Canvas for displaying snapshot image. + * Component for displaying images on a `canvas` element. * * @class * @augments videojs.Component @@ -27,6 +27,49 @@ class RecordCanvas extends Component { innerHTML: '' }); } + + /** + * Clear the `RecordCanvas`s `canvas` element. + */ + clear() { + let canvas = this.el().firstChild; + + canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + } + + /** + * Draw `ImageData` frame onto `canvas` element. + * + * @param {ImageData} [imgData] - ImageData to draw onto canvas. + */ + drawFrame(imgData) { + let canvas = this.el().firstChild; + + // set the image size to the dimensions of the recorded animation + canvas.width = imgData.width; + canvas.height = imgData.height; + + canvas.getContext('2d').putImageData( + imgData, 0, 0, 0, 0, + imgData.width, + imgData.height + ); + } + + /** + * Draw image onto `canvas` element. + * + * @param {HTMLElement} [element] - HTML element to draw onto canvas. + */ + drawImage(element) { + let canvas = this.el().firstChild; + + canvas.getContext('2d').drawImage( + element, 0, 0, + canvas.width, + canvas.height + ); + } } Component.registerComponent('RecordCanvas', RecordCanvas); diff --git a/src/js/defaults.js b/src/js/defaults.js index 3ba6a064..71be9e66 100644 --- a/src/js/defaults.js +++ b/src/js/defaults.js @@ -11,7 +11,7 @@ const pluginDefaultOptions = { audio: false, // Include video in the recorded clip. video: false, - // Animated GIF. + // Animated GIF using the gifshot library. animation: false, // Screen capture. screen: false, @@ -100,6 +100,8 @@ const pluginDefaultOptions = { // Enables the audioBufferUpdate event that provides realtime AudioBuffer // instances from the input audio device. audioBufferUpdate: false, + // Options for animated GIFs using the gifshot library. + animationOptions: {}, // Frame rate in frames per second. animationFrameRate: 200, // Sets quality of color quantization (conversion of images to the diff --git a/src/js/engine/engine-loader.js b/src/js/engine/engine-loader.js index e8458d88..e2c34582 100644 --- a/src/js/engine/engine-loader.js +++ b/src/js/engine/engine-loader.js @@ -7,7 +7,7 @@ import videojs from 'video.js'; import RecordRTCEngine from './record-rtc'; import {CONVERT_PLUGINS, TSEBML, FFMPEGJS, FFMPEGWASM} from './convert-engine'; -import {RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, OPUSRECORDER, OPUSMEDIARECORDER, VMSG, WEBMWASM, AUDIO_PLUGINS} from './record-engine'; +import {RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, OPUSRECORDER, OPUSMEDIARECORDER, VMSG, WEBMWASM, GIFSHOT, AUDIO_PLUGINS} from './record-engine'; /** * Get audio plugin engine class. @@ -89,6 +89,17 @@ const getVideoEngine = function(videoEngine) { return VideoEngineClass; }; +/** + * Get animation plugin engine class. + * + * @private + * @returns {Object} Animation engine plugin. + */ +const getAnimationEngine = function() { + // only gifshot supported at the moment + return videojs.GifshotEngine; +}; + /** * Check whether any audio record plugins are enabled. * @@ -137,5 +148,5 @@ const getConvertEngine = function(convertEngine) { }; export { - getAudioEngine, isAudioPluginActive, getVideoEngine, getConvertEngine + getAudioEngine, isAudioPluginActive, getVideoEngine, getConvertEngine, getAnimationEngine }; diff --git a/src/js/engine/record-engine.js b/src/js/engine/record-engine.js index e002cbd4..22b3be9a 100644 --- a/src/js/engine/record-engine.js +++ b/src/js/engine/record-engine.js @@ -21,7 +21,8 @@ const LAMEJS = 'lamejs'; const OPUSRECORDER = 'opus-recorder'; const OPUSMEDIARECORDER = 'opus-media-recorder'; const VMSG = 'vmsg'; - +// animation +const GIFSHOT = 'gifshot'; // video const WEBMWASM = 'webm-wasm'; @@ -33,9 +34,11 @@ const AUDIO_PLUGINS = [ // all video plugins const VIDEO_PLUGINS = [WEBMWASM]; -// all record plugins -const RECORD_PLUGINS = AUDIO_PLUGINS.concat(VIDEO_PLUGINS); +// all animation plugins +const ANIMATION_PLUGINS = [GIFSHOT]; +// all record plugins +const RECORD_PLUGINS = AUDIO_PLUGINS.concat(VIDEO_PLUGINS).concat(ANIMATION_PLUGINS); /** * Base class for recorder backends. @@ -130,6 +133,6 @@ Component.registerComponent('RecordEngine', RecordEngine); export { RecordEngine, RECORD_PLUGINS, AUDIO_PLUGINS, VIDEO_PLUGINS, - RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, OPUSRECORDER, - OPUSMEDIARECORDER, VMSG, WEBMWASM + ANIMATION_PLUGINS, RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, + OPUSRECORDER, OPUSMEDIARECORDER, VMSG, WEBMWASM, GIFSHOT }; diff --git a/src/js/plugins/gifshot-plugin.js b/src/js/plugins/gifshot-plugin.js new file mode 100644 index 00000000..c73be8f0 --- /dev/null +++ b/src/js/plugins/gifshot-plugin.js @@ -0,0 +1,98 @@ +/** + * @file gifshot-plugin.js + * @since x.x.x + */ + +import gifshot from 'gifshot'; + +const RecordEngine = videojs.getComponent('RecordEngine'); + +/** + * Animated GIF engine for the Gifshot library. + * + * @class + * @augments videojs.RecordEngine + */ +class GifshotEngine extends RecordEngine { + /** + * Setup recording engine. + * + * @param {LocalMediaStream} stream - Media stream to record. + * @param {Object} mediaType - Object describing the media type of this + * engine. + * @param {Boolean} debug - Indicating whether or not debug messages should + * be printed in the console. + */ + setup(stream, mediaType, debug) { + this.inputStream = stream; + this.mediaType = mediaType; + this.debug = debug; + + this.defaultOptions = { + // Desired width of the image + gifWidth: this.options_.width, + // Desired height of the image + gifHeight: this.options_.height, + + // Whether or not you would like the user's camera to stay on + // after the GIF is created. + // Note: The cameraStream Media object is passed back to you in + // the createGIF() callback function + keepCameraOn: true, + // Expects a cameraStream Media object + // Note: Passing an existing camera stream will allow you to + // create another GIF and/or snapshot without asking for the user's + // permission to access the camera again if you are not using SSL + cameraStream: this.inputStream, + // Whether or not you would like to save all of the canvas image + // binary data from your created GIF + // Note: This is particularly useful for when you want to re-use a + // GIF to add text to later + saveRenderingContexts: true + }; + } + + /** + * Start recording. + */ + start() { + //console.log('started recording'); + + let opts = videojs.mergeOptions(this.defaultOptions, + this.animationOptions); + + gifshot.createGIF(opts, this.onRecordingAvailable.bind(this)); + } + + /** + * Stop recording. + */ + stop() { + //console.log('stopped recording'); + } + + /** + * @private + * @param {object} obj - TODO + */ + onRecordingAvailable(obj) { + if (!obj.error) { + // save image data + this.recordedData = obj.image; + + // save first frame + this.recordedFrames = obj.savedRenderingContexts; + + // remove reference to recorded stream + this.dispose(); + + // notify listeners + this.trigger('recordComplete'); + } + } +} + +// expose plugin +videojs.GifshotEngine = GifshotEngine; + +export default GifshotEngine; diff --git a/src/js/videojs.record.js b/src/js/videojs.record.js index 2ef90851..058d8d8b 100644 --- a/src/js/videojs.record.js +++ b/src/js/videojs.record.js @@ -23,7 +23,7 @@ import setSrcObject from './utils/browser-shim'; import compareVersion from './utils/compare-version'; import {detectBrowser} from './utils/detect-browser'; -import {getAudioEngine, isAudioPluginActive, getVideoEngine, getConvertEngine} from './engine/engine-loader'; +import {getAudioEngine, isAudioPluginActive, getVideoEngine, getConvertEngine, getAnimationEngine} from './engine/engine-loader'; import {IMAGE_ONLY, AUDIO_ONLY, VIDEO_ONLY, AUDIO_VIDEO, AUDIO_SCREEN, ANIMATION, SCREEN_ONLY, getRecorderMode} from './engine/record-mode'; const Plugin = videojs.getPlugin('plugin'); @@ -222,8 +222,7 @@ class Record extends Plugin { this.imageOutputQuality = recordOptions.imageOutputQuality; // animation settings - this.animationFrameRate = recordOptions.animationFrameRate; - this.animationQuality = recordOptions.animationQuality; + this.animationOptions = recordOptions.animationOptions; } /** @@ -629,7 +628,9 @@ class Record extends Plugin { // setup recording engine if (this.getRecordType() !== IMAGE_ONLY) { // currently record plugins are only supported in audio-only mode - if (this.getRecordType() !== AUDIO_ONLY && isAudioPluginActive(this.audioEngine)) { + if (this.getRecordType() !== AUDIO_ONLY && + isAudioPluginActive(this.audioEngine) + ) { throw new Error('Currently ' + this.audioEngine + ' is only supported in audio-only mode.'); } @@ -643,6 +644,12 @@ class Record extends Plugin { engineType = this.audioEngine; break; + case ANIMATION: + // get animation plugin engine class + EngineClass = getAnimationEngine(); + engineType = ANIMATION; + break; + default: // get video plugin engine class (or default recordrtc engine) EngineClass = getVideoEngine(this.videoEngine); @@ -693,8 +700,7 @@ class Record extends Plugin { }; // animated GIF settings - this.engine.quality = this.animationQuality; - this.engine.frameRate = this.animationFrameRate; + this.engine.animationOptions = this.animationOptions; // timeSlice if (this.recordTimeSlice && this.recordTimeSlice > 0) { @@ -859,7 +865,8 @@ class Record extends Plugin { break; case ANIMATION: - // hide the first frame + // clear and hide the first frame + this.player.recordCanvas.clear(); this.player.recordCanvas.hide(); // hide the animation @@ -868,13 +875,8 @@ class Record extends Plugin { // show preview video this.mediaElement.style.display = 'block'; - // for animations, capture the first frame - // that can be displayed as soon as recording - // is complete - this.captureFrame().then((result) => { - // start video preview **after** capturing first frame - this.startVideoPreview(); - }); + // preview video stream in video element + this.startVideoPreview(); break; } @@ -1136,6 +1138,7 @@ class Record extends Plugin { this.mediaElement.style.display = 'none'; // show the first frame + this.player.recordCanvas.drawFrame(this.engine.recordedFrames[0]); this.player.recordCanvas.show(); // pause player so user can start playback @@ -1440,6 +1443,7 @@ class Record extends Plugin { case ANIMATION: // reset UI this.player.recordCanvas.hide(); + this.player.animationDisplay.hide(); this.player.cameraButton.hide(); break; } @@ -1628,7 +1632,7 @@ class Record extends Plugin { // take picture imageCapture.grabFrame().then((imageBitmap) => { // get a frame and copy it onto the canvas - this.drawCanvas(recordCanvas, imageBitmap); + this.player.recordCanvas.drawImage(imageBitmap); // notify others resolve(recordCanvas); @@ -1640,27 +1644,13 @@ class Record extends Plugin { // no ImageCapture available: do it the oldskool way // get a frame and copy it onto the canvas - this.drawCanvas(recordCanvas, this.mediaElement); + this.player.recordCanvas.drawImage(this.mediaElement); // notify others resolve(recordCanvas); }); } - /** - * Draw image frame on canvas element. - * @private - * @param {HTMLCanvasElement} canvas - Canvas to draw on. - * @param {HTMLElement} element - Element to draw onto the canvas. - */ - drawCanvas(canvas, element) { - canvas.getContext('2d').drawImage( - element, 0, 0, - canvas.width, - canvas.height - ); - } - /** * Start preview of video stream. * @private @@ -1701,7 +1691,7 @@ class Record extends Plugin { this.player.recordCanvas.hide(); // show the animation - setSrcObject(this.player.recordedData, animationDisplay); + this.player.animationDisplay.load(this.player.recordedData); this.player.animationDisplay.show(); } diff --git a/test/defaults.spec.js b/test/defaults.spec.js index 306f7d4d..7fd5e71e 100644 --- a/test/defaults.spec.js +++ b/test/defaults.spec.js @@ -46,6 +46,7 @@ describe('pluginDefaultOptions', () => { audioWorkerURL: '', audioWebAssemblyURL: '', audioBufferUpdate: false, + animationOptions: {}, animationFrameRate: 200, animationQuality: 10, imageOutputType: 'dataURL', diff --git a/test/engine/record-engine.spec.js b/test/engine/record-engine.spec.js index ff9e3b04..5eb9cbd0 100644 --- a/test/engine/record-engine.spec.js +++ b/test/engine/record-engine.spec.js @@ -3,7 +3,7 @@ */ import TestHelpers from '../test-helpers'; import Event from '../../src/js/event'; -import {RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, OPUSRECORDER, OPUSMEDIARECORDER, VMSG, WEBMWASM, RECORD_PLUGINS, AUDIO_PLUGINS, VIDEO_PLUGINS, RecordEngine} from '../../src/js/engine/record-engine'; +import {RECORDRTC, LIBVORBISJS, RECORDERJS, LAMEJS, OPUSRECORDER, OPUSMEDIARECORDER, VMSG, WEBMWASM, GIFSHOT, RECORD_PLUGINS, AUDIO_PLUGINS, VIDEO_PLUGINS, ANIMATION_PLUGINS, RecordEngine} from '../../src/js/engine/record-engine'; /** @test {record-engine} */ describe('engine.record-engine', () => { @@ -40,6 +40,10 @@ describe('engine.record-engine', () => { expect(VMSG).toEqual('vmsg'); expect(AUDIO_PLUGINS.length).toEqual(6); + // animation + expect(GIFSHOT).toEqual('gifshot'); + expect(ANIMATION_PLUGINS.length).toEqual(1); + // video expect(WEBMWASM).toEqual('webm-wasm'); expect(VIDEO_PLUGINS.length).toEqual(1);