const Runtime = require('../../engine/runtime'); const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const Clone = require('../../util/clone'); const log = require('../../util/log'); const VideoMotion = require('./library'); /** * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const blockIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPm11c2ljLWJsb2NrLWljb248L3RpdGxlPjxkZWZzPjxwYXRoIGQ9Ik0zMi4xOCAyNS44NzRDMzIuNjM2IDI4LjE1NyAzMC41MTIgMzAgMjcuNDMzIDMwYy0zLjA3IDAtNS45MjMtMS44NDMtNi4zNzItNC4xMjYtLjQ1OC0yLjI4NSAxLjY2NS00LjEzNiA0Ljc0My00LjEzNi42NDcgMCAxLjI4My4wODQgMS44OS4yMzQuMzM4LjA4Ni42MzcuMTguOTM4LjMwMi44Ny0uMDItLjEwNC0yLjI5NC0xLjgzNS0xMi4yMy0yLjEzNC0xMi4zMDIgMy4wNi0xLjg3IDguNzY4LTIuNzUyIDUuNzA4LS44ODUuMDc2IDQuODItMy42NSAzLjg0NC0zLjcyNC0uOTg3LTQuNjUtNy4xNTMuMjYzIDE0LjczOHptLTE2Ljk5OCA1Ljk5QzE1LjYzIDM0LjE0OCAxMy41MDcgMzYgMTAuNDQgMzZjLTMuMDcgMC01LjkyMi0xLjg1Mi02LjM4LTQuMTM2LS40NDgtMi4yODQgMS42NzQtNC4xMzUgNC43NS00LjEzNSAxLjAwMyAwIDEuOTc1LjE5NiAyLjg1NS41NDMuODIyLS4wNTUtLjE1LTIuMzc3LTEuODYyLTEyLjIyOC0yLjEzMy0xMi4zMDMgMy4wNi0xLjg3IDguNzY0LTIuNzUzIDUuNzA2LS44OTQuMDc2IDQuODItMy42NDggMy44MzQtMy43MjQtLjk4Ny00LjY1LTcuMTUyLjI2MiAxNC43Mzh6IiBpZD0iYSIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48cGF0aCBzdHJva2Utb3BhY2l0eT0iLjEiIHN0cm9rZT0iIzAwMCIgZD0iTTI4LjQ1NiAyMS42NzVjLS4wMS0uMzEyLS4wODctLjgyNS0uMjU2LTEuNzAyLS4wOTYtLjQ5NS0uNjEyLTMuMDIyLS43NTMtMy43My0uMzk1LTEuOTgtLjc2LTMuOTItMS4xNDItNi4xMTMtLjczMi00LjIyMy0uNjkzLTYuMDUuMzQ0LTYuNTI3LjUtLjIzIDEuMDYtLjA4IDEuODQuMzUuNDE0LjIyNyAyLjE4MiAxLjM2NSAyLjA3IDEuMjk2IDEuOTk0IDEuMjQyIDMuNDY0IDEuNzc0IDQuOTMgMS41NDggMS41MjYtLjIzNyAyLjUwNC0uMDYgMi44NzYuNjE4LjM0OC42MzUuMDE1IDEuNDE2LS43MyAyLjE4LTEuNDcyIDEuNTE2LTMuOTc1IDIuNTE0LTUuODQ4IDIuMDIzLS44MjItLjIyLTEuMjM4LS40NjUtMi4zOC0xLjI2N2wtLjA5NS0uMDY2Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMy4yOTQgMS4zMzYgMi4wOCA5LjE4NyAyLjYzNyAxMS42NzRsLjAwMi4wMTJjLjUyOCAyLjYzNy0xLjg3MyA0LjcyNC01LjIzNiA0LjcyNC0zLjI5IDAtNi4zNjMtMS45ODgtNi44NjItNC41MjgtLjUzLTIuNjQgMS44NzMtNC43MzQgNS4yMzMtNC43MzQuNjcyIDAgMS4zNDcuMDg1IDIuMDE0LjI1LjIyNy4wNTcuNDM2LjExOC42MzYuMTg3em0tMTYuOTk2IDUuOTljLS4wMS0uMzE4LS4wOS0uODM4LS4yNjYtMS43MzctLjA5LS40Ni0uNTk1LTIuOTM3LS43NTMtMy43MjctLjM5LTEuOTYtLjc1LTMuODktMS4xMy02LjA3LS43MzItNC4yMjMtLjY5Mi02LjA1LjM0NC02LjUyNi41MDItLjIzIDEuMDYtLjA4MiAxLjg0LjM1LjQxNS4yMjcgMi4xODIgMS4zNjQgMi4wNyAxLjI5NSAxLjk5MyAxLjI0MiAzLjQ2MiAxLjc3NCA0LjkyNiAxLjU0OCAxLjUyNS0uMjQgMi41MDQtLjA2NCAyLjg3Ni42MTQuMzQ4LjYzNS4wMTUgMS40MTUtLjcyOCAyLjE4LTEuNDc0IDEuNTE3LTMuOTc3IDIuNTEzLTUuODQ3IDIuMDE3LS44Mi0uMjItMS4yMzYtLjQ2NC0yLjM3OC0xLjI2N2wtLjA5NS0uMDY1Yy4wNDcuNTkzLjI2NCAxLjc0LjcxNyAzLjgwMi4yOTQgMS4zMzcgMi4wNzggOS4xOSAyLjYzNiAxMS42NzVsLjAwMy4wMTNjLjUxNyAyLjYzOC0xLjg4NCA0LjczMi01LjIzNCA0LjczMi0zLjI4NyAwLTYuMzYtMS45OTMtNi44Ny00LjU0LS41Mi0yLjY0IDEuODg0LTQuNzMgNS4yNC00LjczLjkwNSAwIDEuODAzLjE1IDIuNjUuNDM2eiIvPjwvZz48L3N2Zz4='; /** * Icon svg to be displayed in the category menu, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const menuIconURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2LjA5IDEyLjkzN2MuMjI4IDEuMTQxLS44MzMgMi4wNjMtMi4zNzMgMi4wNjMtMS41MzUgMC0yLjk2Mi0uOTIyLTMuMTg2LTIuMDYzLS4yMy0xLjE0Mi44MzMtMi4wNjggMi4zNzItMi4wNjguMzIzIDAgLjY0MS4wNDIuOTQ1LjExN2EzLjUgMy41IDAgMCAxIC40NjguMTUxYy40MzUtLjAxLS4wNTItMS4xNDctLjkxNy02LjExNC0xLjA2Ny02LjE1MiAxLjUzLS45MzUgNC4zODQtMS4zNzcgMi44NTQtLjQ0Mi4wMzggMi40MS0xLjgyNSAxLjkyMi0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc3LjEzMiA3LjM3ek03LjQ2IDguNTYzYy0xLjg2Mi0uNDkzLTIuMzI1LTMuNTc2LjEzIDcuMzdDNy44MTYgMTcuMDczIDYuNzU0IDE4IDUuMjIgMThjLTEuNTM1IDAtMi45NjEtLjkyNi0zLjE5LTIuMDY4LS4yMjQtMS4xNDIuODM3LTIuMDY3IDIuMzc1LTIuMDY3LjUwMSAwIC45ODcuMDk4IDEuNDI3LjI3Mi40MTItLjAyOC0uMDc0LTEuMTg5LS45My02LjExNEMzLjgzNCAxLjg3IDYuNDMgNy4wODcgOS4yODIgNi42NDZjMi44NTQtLjQ0Ny4wMzggMi40MS0xLjgyMyAxLjkxN3oiIGZpbGw9IiM1NzVFNzUiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=='; /** * Class for the motion-related blocks in Scratch 3.0 * @param {Runtime} runtime - the runtime instantiating this block package. * @constructor */ class Scratch3VideoSensingBlocks { constructor (runtime) { /** * The runtime instantiating this block package. * @type {Runtime} */ this.runtime = runtime; /** * The motion detection algoritm used to power the motion amount and * direction values. * @type {VideoMotion} */ this.detect = new VideoMotion(); /** * The last millisecond epoch timestamp that the video stream was * analyzed. * @type {number} */ this._lastUpdate = null; /** * Id representing a Scratch Renderer skin the video is rendered to for * previewing. * @type {number} */ this._skinId = -1; /** * The Scratch Renderer Skin object. * @type {Skin} */ this._skin = null; /** * Id for a drawable using the video's skin that will render as a video * preview. * @type {Drawable} */ this._drawable = -1; /** * Canvas DOM element video is rendered to down or up sample to the * expected resolution. * @type {HTMLCanvasElement} */ this._sampleCanvas = null; /** * Canvas 2D Context to render to the _sampleCanvas member. * @type {CanvasRenderingContext2D} */ this._sampleContext = null; // Clear target motion state values when the project starts. this.runtime.on(Runtime.PROJECT_RUN_START, this.reset.bind(this)); // Boot up the video, canvas to down/up sample the video stream, the // preview skin and drawable, and kick off looping the analysis logic. this._setupVideo(); this._setupSampleCanvas(); this._setupPreview(); this._loop(); } /** * Dimensions the video stream is analyzed at after its rendered to the * sample canvas. * @type {Array.} */ static get DIMENSIONS () { return [480, 360]; } /** * Order preview drawable is inserted at in the renderer. * @type {number} */ static get ORDER () { return 1; } /** * The key to load & store a target's motion-related state. * @type {string} */ static get STATE_KEY () { return 'Scratch.videoSensing'; } /** * The default motion-related state, to be used when a target has no existing motion state. * @type {MotionState} */ static get DEFAULT_MOTION_STATE () { return { motionFrameNumber: 0, motionAmount: 0, motionDirection: 0 }; } /** * Reset the extension's data motion detection data. This will clear out * for example old frames, so the first analyzed frame will not be compared * against a frame from before reset was called. */ reset () { this.detect.reset(); const targets = this.runtime.targets; for (let i = 0; i < targets.length; i++) { const state = targets[i].getCustomState(Scratch3VideoSensingBlocks.STATE_KEY); if (state) { state.motionAmount = 0; state.motionDirection = 0; } } } /** * Setup a video element connected to a user media stream. * @private */ _setupVideo () { this._video = document.createElement('video'); navigator.getUserMedia({ audio: false, video: { width: {min: 480, ideal: 640}, height: {min: 360, ideal: 480} } }, stream => { 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._track = stream.getTracks()[0]; }, err => { // @todo Properly handle errors log(err); }); } /** * Create a campus to render the user media video to down/up sample to the * needed resolution. * @private */ _setupSampleCanvas () { // Create low-resolution image to sample video for analysis and preview const canvas = this._sampleCanvas = document.createElement('canvas'); canvas.width = Scratch3VideoSensingBlocks.DIMENSIONS[0]; canvas.height = Scratch3VideoSensingBlocks.DIMENSIONS[1]; this._sampleContext = canvas.getContext('2d'); } /** * Create a Scratch Renderer Skin and Drawable to preview the user media * video stream. * @private */ _setupPreview () { if (this._skinId !== -1) return; if (this._skin !== null) return; if (this._drawable !== -1) return; if (!this.runtime.renderer) return; this._skinId = this.runtime.renderer.createPenSkin(); this._skin = this.runtime.renderer._allSkins[this._skinId]; this._drawable = this.runtime.renderer.createDrawable(); this.runtime.renderer.setDrawableOrder( this._drawable, Scratch3VideoSensingBlocks.ORDER ); this.runtime.renderer.updateDrawableProperties(this._drawable, { skinId: this._skinId }); } /** * Occasionally step a loop to sample the video, stamp it to the preview * skin, and add a TypedArray copy of the canvas's pixel data. * @private */ _loop () { setTimeout(this._loop.bind(this), this.runtime.currentStepTime); // Ensure video stream is established if (!this._video) return; if (!this._track) return; if (typeof this._video.videoWidth !== 'number') return; if (typeof this._video.videoHeight !== 'number') return; // Bail if the camera is *still* not ready const nativeWidth = this._video.videoWidth; const nativeHeight = this._video.videoHeight; if (nativeWidth === 0) return; if (nativeHeight === 0) return; const ctx = this._sampleContext; // Mirror ctx.scale(-1, 1); // Generate video thumbnail for analysis ctx.drawImage( this._video, 0, 0, nativeWidth, nativeHeight, Scratch3VideoSensingBlocks.DIMENSIONS[0] * -1, 0, Scratch3VideoSensingBlocks.DIMENSIONS[0], Scratch3VideoSensingBlocks.DIMENSIONS[1] ); // Restore the canvas transform ctx.resetTransform(); // Render to preview layer if (this._skin !== null) { const xOffset = Scratch3VideoSensingBlocks.DIMENSIONS[0] / 2 * -1; const yOffset = Scratch3VideoSensingBlocks.DIMENSIONS[1] / 2; this._skin.drawStamp(this._sampleCanvas, xOffset, yOffset); this.runtime.requestRedraw(); } // Add frame to detector const time = Date.now(); if (this._lastUpdate === null) this._lastUpdate = time; const offset = time - this._lastUpdate; if (offset > Scratch3VideoSensingBlocks.INTERVAL) { this._lastUpdate = time; const data = ctx.getImageData( 0, 0, Scratch3VideoSensingBlocks.DIMENSIONS[0], Scratch3VideoSensingBlocks.DIMENSIONS[1] ); this.detect.addFrame(data.data); } } /** * Create data for a menu in scratch-blocks format, consisting of an array * of objects with text and value properties. The text is a translated * string, and the value is one-indexed. * @param {object[]} info - An array of info objects each having a name * property. * @return {array} - An array of objects with text and value properties. * @private */ _buildMenu (info) { return info.map((entry, index) => { const obj = {}; obj.text = entry.name; obj.value = String(index + 1); return obj; }); } /** * @param {Target} target - collect motion state for this target. * @returns {MotionState} the mutable motion state associated with that * target. This will be created if necessary. * @private */ _getMotionState (target) { let motionState = target.getCustomState(Scratch3VideoSensingBlocks.STATE_KEY); if (!motionState) { motionState = Clone.simple(Scratch3VideoSensingBlocks.DEFAULT_MOTION_STATE); target.setCustomState(Scratch3VideoSensingBlocks.STATE_KEY, motionState); } return motionState; } /** * An array of choices of whether a reporter should return the frame's * motion amount or direction. * @type {object[]} an array of objects. * @param {string} name - the translatable name to display in the drums menu. * @param {string} fileName - the name of the audio file containing the drum sound. */ get MOTION_DIRECTION_INFO () { return [ { name: 'motion' }, { name: 'direction' } ]; } /** * An array of info about each drum. * @type {object[]} an array of objects. * @param {string} name - the translatable name to display in the drums * menu. * @param {string} fileName - the name of the audio file containing the * drum sound. */ get STAGE_SPRITE_INFO () { return [ { name: 'stage' }, { name: 'sprite' } ]; } /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { return { id: 'videoSensing', name: 'Video Sensing', menuIconURI: menuIconURI, blockIconURI: blockIconURI, blocks: [ { opcode: 'videoOn', blockType: BlockType.REPORTER, text: 'video [MOTION_DIRECTION] on [STAGE_SPRITE]', arguments: { MOTION_DIRECTION: { type: ArgumentType.NUMBER, menu: 'MOTION_DIRECTION', defaultValue: 1 }, STAGE_SPRITE: { type: ArgumentType.NUMBER, menu: 'STAGE_SPRITE', defaultValue: 1 } } }, { // @todo this hat needs to be set itself to restart existing // threads like Scratch 2's behaviour. opcode: 'whenMotionGreaterThan', text: 'when video motion > [REFERENCE]', blockType: BlockType.HAT, arguments: { REFERENCE: { type: ArgumentType.NUMBER, defaultValue: 10 } } } ], menus: { MOTION_DIRECTION: this._buildMenu(this.MOTION_DIRECTION_INFO), STAGE_SPRITE: this._buildMenu(this.STAGE_SPRITE_INFO) } }; } /** * Analyze a part of the frame that a target overlaps. * @param {Target} target - a target to determine where to analyze * @returns {MotionState} the motion state for the given target */ _analyzeLocalMotion (target) { const drawable = this.runtime.renderer._allDrawables[target.drawableID]; const state = this._getMotionState(target); this.detect.getLocalMotion(drawable, state); return state; } /** * A scratch reporter block handle that analyzes the last two frames and * depending on the arguments, returns the motion or direction for the * whole stage or just the target sprite. * @param {object} args - the block arguments * @param {BlockUtility} util - the block utility * @returns {number} the motion amount or direction of the stage or sprite */ videoOn (args, util) { this.detect.analyzeFrame(); let state = this.detect; if (Number(args.STAGE_SPRITE) === 2) { state = this._analyzeLocalMotion(util.target); } if (Number(args.MOTION_DIRECTION) === 1) { return state.motionAmount; } return state.motionDirection; } /** * A scratch hat block edge handle that analyzes the last two frames where * the target sprite overlaps and if it has more motion than the given * reference value. * @param {object} args - the block arguments * @param {BlockUtility} util - the block utility * @returns {boolean} true if the sprite overlaps more motion than the * reference */ whenMotionGreaterThan (args, util) { this.detect.analyzeFrame(); const state = this._analyzeLocalMotion(util.target); return state.motionAmount > Number(args.REFERENCE); } } module.exports = Scratch3VideoSensingBlocks;