UNPKG

scratch-gui

Version:

GraphicaL User Interface for creating and running Scratch 3.0 projects

288 lines (257 loc) • 9.34 kB
import {requestVideoStream, requestDisableVideo} from './camera.js'; import log from '../log.js'; /** * Video Manager for video extensions. */ class VideoProvider { constructor () { /** * Default value for mirrored frames. * @type boolean */ this.mirror = true; /** * Cache frames for this many ms. * @type number */ this._frameCacheTimeout = 16; /** * DOM Video element * @private */ this._video = null; /** * Usermedia stream track * @private */ this._track = null; /** * Stores some canvas/frame data per resolution/mirror states */ this._workspace = []; } static get FORMAT_IMAGE_DATA () { return 'image-data'; } static get FORMAT_CANVAS () { return 'canvas'; } /** * Dimensions the video stream is analyzed at after its rendered to the * sample canvas. * @type {Array.<number>} */ static get DIMENSIONS () { return [480, 360]; } /** * Order preview drawable is inserted at in the renderer. * @type {number} */ static get ORDER () { return 1; } /** * Get the HTML video element containing the stream */ get video () { return this._video; } /** * Request video be enabled. Sets up video, creates video skin and enables preview. * * @return {Promise.<Video>} resolves a promise to this video provider when video is ready. */ enableVideo () { this.enabled = true; return this._setupVideo(); } /** * Disable video stream (turn video off) */ disableVideo () { this.enabled = false; // If we have begun a setup process, call _teardown after it completes if (this._singleSetup) { this._singleSetup .then(this._teardown.bind(this)) .catch(err => this.onError(err)); } } /** * async part of disableVideo * @private */ _teardown () { // we might be asked to re-enable before _teardown is called, just ignore it. if (this.enabled === false) { const disableTrack = requestDisableVideo(); this._singleSetup = null; // by clearing refs to video and track, we should lose our hold over the camera this._video = null; if (this._track && disableTrack) { this._track.stop(); } this._track = null; } } /** * Return frame data from the video feed in a specified dimensions, format, and mirroring. * * @param {object} frameInfo A descriptor of the frame you would like to receive. * @param {Array.<number>} frameInfo.dimensions [width, height] array of numbers. Defaults to [480,360] * @param {boolean} frameInfo.mirror If you specificly want a mirror/non-mirror frame, defaults to true * @param {string} frameInfo.format Requested video format, available formats are 'image-data' and 'canvas'. * @param {number} frameInfo.cacheTimeout Will reuse previous image data if the time since capture is less than * the cacheTimeout. Defaults to 16ms. * * @return {ArrayBuffer|Canvas|string|null} Frame data in requested format, null when errors. */ getFrame ({ dimensions = VideoProvider.DIMENSIONS, mirror = this.mirror, format = VideoProvider.FORMAT_IMAGE_DATA, cacheTimeout = this._frameCacheTimeout }) { if (!this.videoReady) { return null; } const [width, height] = dimensions; const workspace = this._getWorkspace({dimensions, mirror: Boolean(mirror)}); const {videoWidth, videoHeight} = this._video; const {canvas, context, lastUpdate, cacheData} = workspace; const now = Date.now(); // if the canvas hasn't been updated... if (lastUpdate + cacheTimeout < now) { if (mirror) { context.scale(-1, 1); context.translate(width * -1, 0); } context.drawImage(this._video, // source x, y, width, height 0, 0, videoWidth, videoHeight, // dest x, y, width, height 0, 0, width, height ); // context.resetTransform() doesn't work on Edge but the following should context.setTransform(1, 0, 0, 1, 0, 0); workspace.lastUpdate = now; } // each data type has it's own data cache, but the canvas is the same if (!cacheData[format]) { cacheData[format] = {lastUpdate: 0}; } const formatCache = cacheData[format]; if (formatCache.lastUpdate + cacheTimeout < now) { if (format === VideoProvider.FORMAT_IMAGE_DATA) { formatCache.lastData = context.getImageData(0, 0, width, height); } else if (format === VideoProvider.FORMAT_CANVAS) { // this will never change formatCache.lastUpdate = Infinity; formatCache.lastData = canvas; } else { log.error(`video io error - unimplemented format ${format}`); // cache the null result forever, don't log about it again.. formatCache.lastUpdate = Infinity; formatCache.lastData = null; } // rather than set to now, this data is as stale as it's canvas is formatCache.lastUpdate = Math.max(workspace.lastUpdate, formatCache.lastUpdate); } return formatCache.lastData; } /** * Method called when an error happens. Default implementation is just to log error. * * @abstract * @param {Error} error An error object from getUserMedia or other source of error. */ onError (error) { log.error('Unhandled video io device error', error); } /** * Create a video stream. * @private * @return {Promise} When video has been received, rejected if video is not received */ _setupVideo () { // We cache the result of this setup so that we can only ever have a single // video/getUserMedia request happen at a time. if (this._singleSetup) { return this._singleSetup; } this._singleSetup = requestVideoStream({ width: {min: 480, ideal: 640}, height: {min: 360, ideal: 480} }) .then(stream => { this._video = document.createElement('video'); // Use the new srcObject API, falling back to createObjectURL try { this._video.srcObject = stream; } catch (error) { this._video.src = window.URL.createObjectURL(stream); } // Hint to the stream that it should load. A standard way to do this // is add the video tag to the DOM. Since this extension wants to // hide the video tag and instead render a sample of the stream into // the webgl rendered Scratch canvas, another hint like this one is // needed. this._video.play(); // Needed for Safari/Firefox, Chrome auto-plays. this._track = stream.getTracks()[0]; return this; }) .catch(error => { this._singleSetup = null; this.onError(error); }); return this._singleSetup; } get videoReady () { if (!this.enabled) { return false; } if (!this._video) { return false; } if (!this._track) { return false; } const {videoWidth, videoHeight} = this._video; if (typeof videoWidth !== 'number' || typeof videoHeight !== 'number') { return false; } if (videoWidth === 0 || videoHeight === 0) { return false; } return true; } /** * get an internal workspace for canvas/context/caches * this uses some document stuff to create a canvas and what not, probably needs abstraction * into the renderer layer? * @private * @return {object} A workspace for canvas/data storage. Internal format not documented intentionally */ _getWorkspace ({dimensions, mirror}) { let workspace = this._workspace.find(space => ( space.dimensions.join('-') === dimensions.join('-') && space.mirror === mirror )); if (!workspace) { workspace = { dimensions, mirror, canvas: document.createElement('canvas'), lastUpdate: 0, cacheData: {} }; workspace.canvas.width = dimensions[0]; workspace.canvas.height = dimensions[1]; workspace.context = workspace.canvas.getContext('2d'); this._workspace.push(workspace); } return workspace; } } export default VideoProvider;