UNPKG

handsfree

Version:

A library for creating head-controlled, handsfree user interfaces via computer vision just...like...✨...that!

420 lines (385 loc) 13.8 kB
/** * ✨ * (\. \ ,/) * \( |\ )/ * //\ | \ /\\ * (/ /\_#👓#_/\ \) * \/\ #### /\/ * `##' * * 🔮 /Handsfree.js 🔮 * * @description Use computer vision to handsfree-ify websites, apps, games, * tools, robotics and anything else with a webcam just...like...✨...that! * * # NOTES * - via Node: This is what you get when you require('handsfree') * -- eg: const Handsfree = require('handsfree') * const handsfree = new Handsfree(opts) * * - via <script>: This class is exposed via a global Handsfree module object * -- eg: <script src="https://unpkg.com/handsfree/@<4/dist/handsfree.js"></script> * <script>const handsfree = new Handsfree(opts)</scrtip> * * - **Caps matter** * -- Handsfree with capital H refers to the class * -- We use handsfree with a lower h to refer to an instance, like: const handsfree = new Handsfree() * * --- * * # Globals * The following globals are added when using handsfree.js: * * - `Handsfree` * - `HandsfreePose` * * --- * * # HOW TO HELP * - For improvements to the cursor, @see /handsfree.js/components/Cursor.js * - For details about BRFv4 and the faces object @see https://github.com/Tastenkunst/brfv4_javascript_examples * * - Star and fork the project on GitHub @see https://github.com/labofoz/handsfreejs * - Check out existing issues @see https://github.com/labofoz/handsfreejs/issues * - Search this project for "@todo" * --- * * @see handsfree.js.org * @see https://github.com/labofoz/handsfreejs * @see https://glitch.com/@handsfreejs * @see https://unpkg.com/handsfree/@<4/dist/handsfree.js * @see https://twitter.com/labofoz * */ const {trimStart, merge, forEach} = require('lodash') class Handsfree { /** * Doing nothing by default (eg: new Handsfree()) would: * - Inject and hide necessary video/canvas elements * - Begin downloading the BRFv4 model (~9mb) * * @param {Object} opts The config object, @see /README.md * * @emits handsfree:instantiated Helps disconnected parts of your app know that handsfree is ready */ constructor (opts = {}) { /** * A collection of pose objects {face} for this.settings.maxPoses */ this.pose = [] /** * Your settings * - This really just acts as a namespace for plugins to pull settings from * - To set a setting during instantiation, use: * const handsfree = new Handsfree({setting: {mySetting: value}}) */ this.opts = opts opts.settings = opts.settings || {} this.settings = merge(defaultSettings, opts.settings) /** * This will store all the plugins by name: handsfree.use({name: 'myPlugin}) * - Adds it in here as: handsfree.plugin{myPlugin: {...}} * - And if you need to acess a plugin, just use: handsfree.plugin[pluginName] */ this.plugin = {} /** * Contains a collection of all the handsfree.on(eventName, callback) * - Everytime you `handsfree.on(eventName, callback)`, it's added to * as: `this.listening[eventName].push(callback)` * - Calling `handsfree.off()` will stop listening to events */ this.listening = {} /** * Contains the state of the core debugger, which includes: * - <div> container with * -- a <video> to grab the webcam stream from * -- a <canvas> to draw debug info on */ this.debug = { // Whether to show the core debugger (true) or not (false) isEnabled: !!opts.debug, // Whether we're actually debugging or not isDebugging: false, // The webcam stream $webcam: null, // The canvas to display debug info on $canvas: null, // The canvas context ctx: null, // The wrapping element $wrap: null } /** * Configs for trackers */ this.tracker = { brf: { // Whether BRFv4 is disabled or not _isDisabled: !this.settings.tracker.brf.enabled, // The BRFv4 model model: null, // Whether the BRFv4 model has been loaded isReady: false, // Whether the model is being loaded or not isLoading: false, }, posenet: { // Whether posenet is disabled or not _isDisabled: !this.settings.tracker.posenet.enabled, // The PoseNet model model: null, // Whether the posenet model has been loaded isReady: false, // Whether the model is being loaded or not isLoading: false, } } /** * Configs for BRFv4 * @see https://tastenkunst.github.io/brfv4_docs/ */ this.brf = { // Will fallback to ASM if Web ASM isn't supported baseURL: `${Handsfree.libPath}brf/`, // The BRFv4 Manager manager: {}, // The BRFv4 Resolution resolution: null, // The loaded BRFv4 sdk library sdk: null, // The SDK version we're using sdkName: 'BRFv4_JS_TK110718_v4.1.0_trial', // The Web ASM buffer WASMBuffer: null } /** * Cursor properties * @todo This should be an array for multi-user support @see https://github.com/BrowseHandsfree/handsfreeJS/issues/46 */ this.cursor = { // Position on window x: -100, y: -100, // The actual cursor element $el: null } // Helper object to remove jittering this.tweenFaces = [] this.tweenBody = [] // True when webcam stream is set and poses are being tracked // - this.isTracking && requestAnimationFrame(Handsfree.trackPoses()) this.isTracking = false // Whether Web Assembly is supported this.isWASMSupported = typeof WebAssembly === 'object' // Whether handsfree is supported this.isSupported = this.checkForMediaSupport() /** * Initialize the instance * Let the browser know that we've finished instantiated */ this.init() this.toggleCursor(!opts.hideCursor) document.body.classList.add('handsfree-stopped') window.dispatchEvent(new CustomEvent('handsfree:instantiated', {detail: opts})) } /** * Starts the webcam stream * - Adds .handsfree-started and removes -handsfree-stopped from the <body> * - This stream is completely killed when handsfree.stop() is called * -- A new stream is therefore created every time handsfree.start() is called after handsfree.stop() * -- This takes a few moments to happen (less than a second in my experience) * - If models are not initialized, it'll do so first * - If models are initialized, it'll start pose estimation * * @emits handsfree:loading {detail: {progress: 0}} Useful giving user feedback */ start () { this.toggleDebugger(this.debug.isEnabled) document.body.classList.add('handsfree-started') document.body.classList.remove('handsfree-stopped') window.dispatchEvent(new CustomEvent('handsfree:loading', {detail: {progress: 0}})) window.navigator.mediaDevices.getUserMedia(this.settings.webcam).then(mediaStream => { this.debug.$webcam.srcObject = mediaStream this.debug.$webcam.play() this.onStartHooks() if (this.settings.tracker.brf.enabled) { if (!this.brf.sdk) { window.dispatchEvent(new CustomEvent('handsfree:loading', {detail: {progress: 10}})) this.startBRFv4() } else { window.dispatchEvent(new CustomEvent('handsfree:loading', {detail: {progress: 100}})) this.isTracking = true this.brf.manager.setNumFacesToTrack(this.settings.maxPoses) this.trackPoses() } } else if (this.settings.tracker.posenet.enabled) { this.isTracking = true this.resizeCanvas() this.trackPoses() } }) } /** * Stop tracking and release webcam streams * - Removes .handsfree-started and adds .handsfree-stopped to <body> * - Kills all webcam streams * - Disables debugger */ stop () { document.body.classList.remove('handsfree-started') document.body.classList.add('handsfree-stopped') if (this.isTracking) { this.isTracking = false this.debug.$webcam.srcObject.getTracks().forEach(track => track.stop()) this.toggleDebugger(false) this.onStopHooks() } } /** * Goes through and tracks poses for all active models */ trackPoses () { this.debug.ctx.clearRect(0, 0, this.debug.$canvas.width, this.debug.$canvas.height) // BRFv4 (face tracker) if (!this.tracker.brf._isDisabled && this.tracker.brf.isReady) { this.trackFaces() } // PoseNet (full body pose estimator) if (!this.tracker.posenet._isDisabled && this.tracker.posenet.isReady) { this.trackBody() } // Do things with poses this.setPosesFromCache() this.debug.isDebugging && this.debugPoses() this.getCursors() this.setTouchedElement() this.onFrameHooks(this.pose) /** * Dispatch global event and reloop * - Only reloops if .isTracking */ window.dispatchEvent(new CustomEvent('handsfree:trackPoses', {detail: { scope: this, poses: this.pose }})) this.isTracking && requestAnimationFrame(() => this.trackPoses()) } /** * Sets the cursor, based on the dominant tracker */ getCursors () { if (!this.tracker.brf._isDisabled) { this.getBRFCursors() } else if (!this.tracker.posenet._isDisabled) { this.getPoseNetCursors() } } /** * Updates this.pose with cached data */ setPosesFromCache () { forEach(this.poseCache, (cache, pose) => { for (let i = 0; i < cache.length; i++) { this.pose[i][pose] = cache[i] } }) } /** * Returns the element under the cursor and stores it as pose.cursor.$target * - If there's no target, then null is returned * * @todo move this to Cursor.js */ setTouchedElement () { this.pose.forEach(pose => { if (pose.cursor && pose.cursor.x && pose.cursor.y) { pose.cursor.$target = document.elementFromPoint(pose.cursor.x, pose.cursor.y) } }) } /** * Dispatches an event to `handsfree:${eventName}` * * @param {String} eventName The event name to dispatch, appended to `handsfree:` * @param {Any} args Any extra arguments to pass */ dispatch (eventName, ...args) { window.dispatchEvent(new CustomEvent(`handsfree:${eventName}`, {detail: args})) } /** * Adds a listener to `handsfree:${eventName}` * - The callback receives the arguments, not the event object * - Passes over any additional arguments * * @param {String} eventName The event name to call, appended to `handsfree:` * @param {Function} callback The callback to call */ on (eventName, callback) { const self = this const handler = function (ev) {callback.call(self, ev)} window.addEventListener(`handsfree:${eventName}`, handler) if (!this.listening[eventName]) this.listening[eventName] = [] this.listening[eventName].push(handler) } /** * Stops listening to events * @param {String} eventName The event name to stop listening to * - Leave empty to turn off ALL events */ off (eventName = null) { // Remove by name if (eventName) { // Only remove listeners that exist if (this.listening[eventName]) { this.listening[eventName].forEach(callback => { window.removeEventListener(`handsfree:${eventName}`, callback) }) delete this.listening[eventName] } // Remove all } else { forEach(this.listening, (callback, eventName) => {this.off(eventName)}) } } /** * Toggle Cursor * @param {Boolean|Null} state Whether to turn it on (true), off (false), or flip between the two (null) */ toggleCursor (state = null) { if (typeof state === 'boolean') this.settings.hideCursor = !state else this.settings.hideCursor = !this.settings.hideCursor if (this.settings.hideCursor) { document.body.classList.add('handsfree-hide-cursor') } else { document.body.classList.remove('handsfree-hide-cursor') } } } /** * Configs * @todo make use of environment variables too @see https://github.com/BrowseHandsfree/handsfreeJS/issues/48 */ const defaultSettings = require('./config/default-settings') const pkg = require('../package.json') // Add class to body to style loading document.body.classList.add('handsfree-is-loading') // Set the lib path to whereever this file is, this is required for loading the BRFv4 SDK const libSrc = document.currentScript.getAttribute('src') Handsfree.libPath = trimStart(libSrc.replace('handsfree.js', ''), '/') // Set the lib domain too if (libSrc[0] === '/' || libSrc.substring(0, 4) !== 'http') { Handsfree.libDomain = window.location.origin + '/' + Handsfree.libPath } else { Handsfree.libDomain = Handsfree.libPath } // Let the magic begin ✨ require('./methods/Setup')(Handsfree) require('./methods/Util')(Handsfree) require('./methods/Debug')(Handsfree) require('./methods/Plugin')(Handsfree) require('./trackers/BRF')(Handsfree) require('./trackers/PoseNet')(Handsfree) // Finally, include stylesheets Handsfree.version = pkg.version require('../public/handsfree.styl') module.exports = Handsfree