From 8c0d895cacbd728b43790cbbb10fb9c120e610cd Mon Sep 17 00:00:00 2001 From: Karishma Chadha <kchadha@scratch.mit.edu> Date: Wed, 25 Apr 2018 23:09:00 -0400 Subject: [PATCH] Camera costume upload functionality. Needs UI and code cleanup. --- src/components/camera-modal/camera-modal.css | 118 ++++ src/components/camera-modal/camera-modal.jsx | 88 +++ src/components/camera-modal/icon--back.svg | Bin 0 -> 2848 bytes src/containers/camera-modal.jsx | 89 +++ src/containers/costume-tab.jsx | 31 +- src/containers/stage.jsx | 4 +- src/lib/camera.js | 654 +++++++++++++++++++ src/lib/file-uploader.js | 3 +- src/reducers/modals.js | 11 + 9 files changed, 994 insertions(+), 4 deletions(-) create mode 100644 src/components/camera-modal/camera-modal.css create mode 100644 src/components/camera-modal/camera-modal.jsx create mode 100644 src/components/camera-modal/icon--back.svg create mode 100644 src/containers/camera-modal.jsx create mode 100644 src/lib/camera.js diff --git a/src/components/camera-modal/camera-modal.css b/src/components/camera-modal/camera-modal.css new file mode 100644 index 000000000..c86e7f0ca --- /dev/null +++ b/src/components/camera-modal/camera-modal.css @@ -0,0 +1,118 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.modal-content { + width: 600px; +} + +.body { + background: $ui-white; + padding: 1.5rem 2.25rem; +} + +.visualization-container { + display: flex; + justify-content: space-around; +} + +.camera-feed-container { + background: $ui-primary; + border: 1px solid $ui-black-transparent; + border-radius: 5px; + padding: 3px; + +} + +.camera-feed-container { + display: flex; + justify-content: space-around; + align-items: center; + width: 480px; + height: 360px; + position: relative; +} + +.canvas { + width: 480px; + height: 380px; +} + +.help-text { + margin: 10px auto 0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: rgb(167, 170, 181); + font-size: 0.95rem; + font-weight: 500; +} + +.capture-text { + color: $motion-primary; +} + +.main-button-row { + display: flex; + justify-content: space-around; + margin-top: 15px; +} + +.main-button-row button { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background: transparent; + border: none; +} + +.main-button-row button:disabled { + opacity: 0.25; +} + +.main-button-row button:active, .main-button-row button:focus { + outline: none; +} + +.button-row { + font-weight: bolder; + text-align: right; + display: flex; + justify-content: space-between; +} + +.button-row button { + padding: 0.75rem 1rem; + border-radius: 0.25rem; + background: white; + border: 1px solid $ui-black-transparent; + font-weight: 600; + font-size: 0.85rem; + color: $motion-primary; +} + +.button-row button.ok-button { + background: $motion-primary; + border: $motion-primary; + color: white; +} + +.button-row button + button { + margin-left: 0.5rem; +} + +.main-button { + text-align: center; +} + +.capture-button { + overflow: visible; +} + +.capture-button-circle { + fill: $motion-primary; + /* opacity: 0.25; */ + stroke: $motion-tertiary; +} + +.capture-button-circle-outline { + fill: $ui-black-transparent; + opacity: 0.25; + transition: 0.1s; +} diff --git a/src/components/camera-modal/camera-modal.jsx b/src/components/camera-modal/camera-modal.jsx new file mode 100644 index 000000000..c39070c6a --- /dev/null +++ b/src/components/camera-modal/camera-modal.jsx @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Box from '../box/box.jsx'; +import Modal from '../modal/modal.jsx'; +import styles from './camera-modal.css'; +import backIcon from './icon--back.svg'; + +const CameraModal = props => ( + <Modal + className={styles.modalContent} + contentLabel={'Take a Picture'} + onRequestClose={props.onCancel} + > + <Box className={styles.body}> + <Box className={styles.visualizationContainer}> + <Box className={styles.cameraFeedContainer}> + <canvas + className={styles.canvas} + // height and (below) width of the actual image + height="720" + ref={props.canvasRef} + width="960" + /> + </Box> + </Box> + {props.capture ? + <Box className={styles.buttonRow}> + <button + className={styles.cancelButton} + onClick={props.onBack} + > + <img + draggable={false} + src={backIcon} + /> Re-take + </button> + <button + className={styles.okButton} + onClick={props.onSubmit} + > Save + </button> + </Box> : + <Box className={styles.mainButtonRow}> + <button + className={styles.mainButton} + onClick={props.onCapture} + > + <svg + className={styles.captureButton} + height="52" + width="52" + > + <circle + className={styles.captureButtonCircle} + cx="26" + cy="26" + r="25" + /> + <circle + className={styles.captureButtonCircleOutline} + cx="26" + cy="26" + r="27" + /> + </svg> + <div className={styles.helpText}> + <span className={styles.captureText}> + {'Take Photo'} + </span> + </div> + </button> + </Box> + } + </Box> + </Modal> +); + +CameraModal.propTypes = { + canvasRef: PropTypes.func.isRequired, + capture: PropTypes.instanceOf(ImageData), + onBack: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onCapture: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired + // videoRef: PropTypes.func.isRequired +}; + +export default CameraModal; diff --git a/src/components/camera-modal/icon--back.svg b/src/components/camera-modal/icon--back.svg new file mode 100644 index 0000000000000000000000000000000000000000..47d09bc7fad766b50da3ed1cffec190b1e94878c GIT binary patch literal 2848 zcmZ{m+fE}#6h+_XD>|K*AkpQzUq&_~#nI%2NTbZ;1037~7RHV24v@dkI@K6V#PkdH zcCpVs`&_E$pPufv%|pLGY=+&UHA=Tlzq=aNo89%I{p;K3F1O8LT<zAY?Xc???QUp4 zU3{1y9<H0m&3e4yfAalP+uZb<>zi@W)-Mm6{_)r0Y0>INHzqf5oP;oKL(tt}(cX;X z{m0qt@$peTIyLOCXI5)HgZ%l($EWRPcl+K!Q%afDU)zfh4bS|iu4{hpcl~}f4*QSI z*V}%)x@o*qw)w>s9m`qMH761O9&Y-(&FVQ>bv4}04%7d1-81F58Mpn#zVG(^)v#aB zXA>~RTla^ni{-vwjr|%g#+&DmYCfw!9Z!8fTnNIS3At`I>qYxzb=`M3aTxc*ZS{56 zAHQ`+XWQocX1f*B<wyD4?f=<wx_{_*!+QOkdQE?#{jERTj>CP|b;f4>lq~fe_p9CE z`>?-TwDsc_#lLjkM}^ru=S}B>fBtfIjQO))mh}1aNr;m$U&sEw^F;ZwUHwa|bl>)y zyB|c-FJZiW2{L&hjK^m_CGh9b>>sdscT{@bczxtP`=87g()$&4!R|k-Q?Ue-k~=q9 z>71iYwz;%g1)YP{{@facWiflxEP-t-#zZS{Fh1t&8dFs9!I<fA^f4Bl1dUK=Un!30 z64)lEOMU|ryZEAujc<svN;-RDoT|7qK?UoyGvQS864->KEof57+Gs^ZP#R)OdP$iS zqtQh`>p2<Yxrv0Zk^gFh!26(`HUe9lL-BI9Xro=W=kn3ofJY6#dy}+J{uI3=Mu<+w zR8HZgz)oOP1cenLJ2;Z^A!rd1lyg3$Yy@=V7F|WP2DKJxi6WR7EyNx{nM8vewlcyN z!Cqx!1N9Kp6r-a-u~*5&5L0E~cO4};Un>(s4QCv@x}HTrK%FpXZ*<6r@OT146%kb~ z#H}bIL&*4pfidVHf@#5+1CO8-A2ZSGRL(YpX9kNj9|-4E@H+U22(dU}G4`;Q2<J6v zP>oWkGJ>6=D#1w_)RU&NE}a`o${u53AfrIt&|p<0niB#q&8l^Q0F-=ekt(AH4cbyV ziw49<4~raI3Y{&eqxZ8F)VW-W^wcXV6xKz=RT05i4kN-TQY)Fxmct=};)H0=C8vov z%Grnts|s^Mi`B!}W;5LiYM~H+ph%*PMBYV(f}<s+DQoI%3$?S#7*Cn1HYZ+FBsNU4 z1<0sOkxleff+mYtuw8~3SPtTUNe<5pZA1k<HlQWKnJa-tmbPqz_hHb;t&$3!ac^RS z=Bg7E1zBW)VwpCsQY4y0h6_|GE8|IFEoU9VwAch%rQvwVaY9X|JA{D}QRX5Ou4xF- zSzS-q!6T?Fks@8`741ZbiqdAj(CtfLShW#<w21eGR-@Bkp#QKJRN^_bu^527#~_F! z)r!(oQ57aU$S@<>02h2qB+Vq2{Kn0!9CJVAEStv|VaS|iuv0xNvupwkMQNLKsV&7o z1|ubAg;HQTO}Yf5*oFm$B0I^*fLrA<sHDsmwz8UQlLR{1im;-R2aRNGSrIunBOOub z4rZg&_TwrD&Nu9Qt<h-EKrd?sjf_EA9LdNW()U!^!7CR;1hq0;P9<QBki)boH!p(O zC-h__Ww}_Ol`6B+P{zfx1a!U_7a(9OGC?WJNyd~|r1rW$>kWhgnYY)?d}-+(%@t+f zeTA1?bfTB_bqa%W^_kk)FDoCgBxC5oH`jyp%5eFi==3fDW!0MO$nEvodG}gv>l@C? zYts*Ia=C_MF0{Ec8%c91o;8Q7)t0vt19;Z#hjDsWVHwuvY<iiQ-e67$x)X+$*DbmB Y-+9HDUB3nAr@+gX`e80_p%)+i0|$YAnE(I) literal 0 HcmV?d00001 diff --git a/src/containers/camera-modal.jsx b/src/containers/camera-modal.jsx new file mode 100644 index 000000000..1b7a5de9e --- /dev/null +++ b/src/containers/camera-modal.jsx @@ -0,0 +1,89 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import CameraModalComponent from '../components/camera-modal/camera-modal.jsx'; +import {ModalVideoProvider} from '../lib/camera.js'; + +import { + closeCameraCapture +} from '../reducers/modals'; + +class CameraModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleCapture', + // 'setVideoInput', + 'handleSubmit', + 'handleCancel', + 'setCanvas' + // 'enableVideo' + ]); + + this.video = null; + this.videoDevice = null; + + this.state = { + capture: null + }; + } + componentWillUnmount () { + // const videoDevice = this.props.vm.runtime.ioDevices.video; + // videoDevice.disableVideo(); + this.videoDevice.disableVideo(); + // this.video = null; + } + handleCapture () { + const capture = this.videoDevice.takeSnapshot(); + this.setState({capture: capture}); + } + setCanvas (canvas) { + this.canvas = canvas; + if (this.canvas) { + this.videoDevice = new ModalVideoProvider(this.canvas); + this.videoDevice.enableVideo(); + } + } + handleSubmit () { + if (!this.state.capture) return; + this.props.onNewCostume(this.state.capture); + this.props.onClose(); + } + handleCancel () { + this.props.onClose(); + } + render () { + return ( + <CameraModalComponent + // vm={this.props.vm} + // onBack={this.handleBack} + canvasRef={this.setCanvas} + capture={this.state.capture} + // videoRef={this.setVideoInput} + onCancel={this.handleCancel} + onCapture={this.handleCapture} + onSubmit={this.handleSubmit} + /> + ); + } +} + +CameraModal.propTypes = { + onClose: PropTypes.func, + onNewCostume: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = dispatch => ({ + onClose: () => { + dispatch(closeCameraCapture()); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CameraModal); diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 6042ceaaa..5146a4f74 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -8,12 +8,15 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import CostumeLibrary from './costume-library.jsx'; import BackdropLibrary from './backdrop-library.jsx'; +import CameraModal from './camera-modal.jsx'; import {connect} from 'react-redux'; import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; import { + closeCameraCapture, closeCostumeLibrary, closeBackdropLibrary, + openCameraCapture, openCostumeLibrary, openBackdropLibrary } from '../reducers/modals'; @@ -79,6 +82,7 @@ class CostumeTab extends React.Component { 'handleSurpriseBackdrop', 'handleFileUploadClick', 'handleCostumeUpload', + 'handleCameraBuffer', 'setFileInput' ]); const { @@ -180,6 +184,10 @@ class CostumeTab extends React.Component { costumeUpload(buffer, fileType, fileName, storage, this.handleNewCostume); }); } + handleCameraBuffer (buffer) { + const storage = this.props.vm.runtime.storage; + costumeUpload(buffer, 'image/png', 'costume1', storage, this.handleNewCostume); + } handleFileUploadClick () { this.fileInput.click(); } @@ -194,11 +202,14 @@ class CostumeTab extends React.Component { render () { const { intl, + onNewCostumeFromCameraClick, onNewLibraryBackdropClick, onNewLibraryCostumeClick, backdropLibraryVisible, + cameraModalVisible, costumeLibraryVisible, onRequestCloseBackdropLibrary, + onRequestCloseCameraModal, onRequestCloseCostumeLibrary, editingTarget, sprites, @@ -234,7 +245,8 @@ class CostumeTab extends React.Component { }, { title: intl.formatMessage(messages.addCameraCostumeMsg), - img: cameraIcon + img: cameraIcon, + onClick: onNewCostumeFromCameraClick }, { title: intl.formatMessage(addFileMessage), @@ -280,6 +292,13 @@ class CostumeTab extends React.Component { onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} + {cameraModalVisible ? ( + <CameraModal + vm={vm} + onClose={onRequestCloseCameraModal} + onNewCostume={this.handleCameraBuffer} + /> + ) : null} </AssetPanel> ); } @@ -287,12 +306,15 @@ class CostumeTab extends React.Component { CostumeTab.propTypes = { backdropLibraryVisible: PropTypes.bool, + cameraModalVisible: PropTypes.bool, costumeLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, intl: intlShape, + onNewCostumeFromCameraClick: PropTypes.func.isRequired, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, onRequestCloseBackdropLibrary: PropTypes.func.isRequired, + onRequestCloseCameraModal: PropTypes.func.isRequired, onRequestCloseCostumeLibrary: PropTypes.func.isRequired, sprites: PropTypes.shape({ id: PropTypes.shape({ @@ -315,6 +337,7 @@ const mapStateToProps = state => ({ editingTarget: state.targets.editingTarget, sprites: state.targets.sprites, stage: state.targets.stage, + cameraModalVisible: state.modals.cameraCapture, costumeLibraryVisible: state.modals.costumeLibrary, backdropLibraryVisible: state.modals.backdropLibrary }); @@ -328,11 +351,17 @@ const mapDispatchToProps = dispatch => ({ e.preventDefault(); dispatch(openCostumeLibrary()); }, + onNewCostumeFromCameraClick: () => { + dispatch(openCameraCapture()); + }, onRequestCloseBackdropLibrary: () => { dispatch(closeBackdropLibrary()); }, onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); + }, + onRequestCloseCameraModal: () => { + dispatch(closeCameraCapture()); } }); diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 96fdf71a6..e6918fdd2 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -6,7 +6,7 @@ import VM from 'scratch-vm'; import {connect} from 'react-redux'; import {getEventXY} from '../lib/touch-utils'; -import VideoProvider from '../lib/video/video-provider'; +import {stageVideoProvider} from '../lib/camera'; import StageComponent from '../components/stage/stage.jsx'; @@ -58,7 +58,7 @@ class Stage extends React.Component { this.renderer = new Renderer(this.canvas); this.props.vm.attachRenderer(this.renderer); this.props.vm.runtime.addListener('QUESTION', this.questionListener); - this.props.vm.setVideoProvider(new VideoProvider()); + this.props.vm.setVideoProvider(stageVideoProvider(this.props.vm.runtime)); } shouldComponentUpdate (nextProps, nextState) { return this.props.width !== nextProps.width || diff --git a/src/lib/camera.js b/src/lib/camera.js new file mode 100644 index 000000000..981ee972d --- /dev/null +++ b/src/lib/camera.js @@ -0,0 +1,654 @@ +import getUserMedia from 'get-user-media-promise'; +import log from './log.js'; + +class StageVideoProvider { + constructor (runtime) { + /** + * Reference to the owning Runtime. + * @type{!Runtime} + */ + this.runtime = runtime; + + /** + * 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 = []; + + /** + * 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; + + /** + * Store the last state of the video transparency ghost effect + * @type {number} + */ + this._ghost = 0; + } + + 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; + } + + /** + * Request video be enabled. Sets up video, creates video skin and enables preview. + * + * ioDevices.video.requestVideo() + * + * @return {Promise.<Video>} resolves a promise to this IO device 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) { + this._disablePreview(); + this._singleSetup = null; + // by clearing refs to video and track, we should lose our hold over the camera + this._video = null; + if (this._track) { + 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 the global + * mirror state (ioDevices.video.mirror) + * @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 = StageVideoProvider.DIMENSIONS, + mirror = this.mirror, + format = StageVideoProvider.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(); + 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 === StageVideoProvider.FORMAT_IMAGE_DATA) { + formatCache.lastData = context.getImageData(0, 0, width, height); + } else if (format === StageVideoProvider.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; + } + + /** + * Set the preview ghost effect + * @param {number} ghost from 0 (visible) to 100 (invisible) - ghost effect + */ + setPreviewGhost (ghost) { + this._ghost = ghost; + if (this._drawable) { + this.runtime.renderer.updateDrawableProperties(this._drawable, {ghost}); + } + } + + /** + * 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. + * Should probably be moved to -render or somewhere similar later + * @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 = getUserMedia({ // navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + 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]; + this._setupPreview(); + return this; + }) + .catch(error => { + this._singleSetup = null; + this.onError(error); + }); + + return this._singleSetup; + } + + _disablePreview () { + if (this._skin) { + this._skin.clear(); + this.runtime.renderer.updateDrawableProperties(this._drawable, {visible: false}); + } + this._renderPreviewFrame = null; + } + + _setupPreview () { + const {renderer} = this.runtime; + if (!renderer) return; + + if (this._skinId === -1 && this._skin === null && this._drawable === -1) { + this._skinId = renderer.createPenSkin(); + this._skin = renderer._allSkins[this._skinId]; + this._drawable = renderer.createDrawable(); + renderer.setDrawableOrder( + this._drawable, + StageVideoProvider.ORDER + ); + renderer.updateDrawableProperties(this._drawable, { + skinId: this._skinId + }); + } + + // if we haven't already created and started a preview frame render loop, do so + if (!this._renderPreviewFrame) { + renderer.updateDrawableProperties(this._drawable, { + ghost: this._ghost, + visible: true + }); + + this._renderPreviewFrame = () => { + clearTimeout(this._renderPreviewTimeout); + if (!this._renderPreviewFrame) { + return; + } + + this._renderPreviewTimeout = setTimeout(this._renderPreviewFrame, this.runtime.currentStepTime); + + const canvas = this.getFrame({format: StageVideoProvider.FORMAT_CANVAS}); + + if (!canvas) { + this._skin.clear(); + return; + } + + const xOffset = StageVideoProvider.DIMENSIONS[0] / -2; + const yOffset = StageVideoProvider.DIMENSIONS[1] / 2; + this._skin.drawStamp(canvas, xOffset, yOffset); + this.runtime.requestRedraw(); + }; + + this._renderPreviewFrame(); + } + } + + 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; + } +} + +class ModalVideoProvider { + constructor (canvas) { + /** + * Captured image data + */ + this._capture = null; + + /** + * Cache frames for this many ms. + * @type number + */ + this._frameCacheTimeout = 16; + + this._canvas = canvas; + + this._video = null; + + // this._workspace = []; + + /** + * Usermedia stream track + * @private + */ + this._track = null; + } + + 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]; + } + + setVideo (video) { + this._video = video; + } + + 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; + } + + enableVideo () { + const thisContext = this; + this._video = this._video ? this._video : document.createElement('video'); + // TODO possibly make common function for this + getUserMedia({ + audio: false, + video: true + }) + .then(userMediaStream => { + try { + thisContext._video.srcObject = userMediaStream; + } catch (e) { + thisContext._video.src = window.URL.createObjectURL(userMediaStream); + } + thisContext._track = userMediaStream.getTracks()[0]; + + const width = 960; // abstract this out + const height = 720; + + const ctx = thisContext._canvas.getContext('2d'); + + ctx.scale(-1, 1); + ctx.translate(width * -1, 0); + + thisContext._videoFeedInterval = setInterval(() => ctx.drawImage(thisContext._video, + // source x, y, width, height + 0, 0, thisContext._video.videoWidth, thisContext._video.videoHeight, + // dest x, y, width, height + 0, 0, width, height + ), thisContext._frameCacheTimeout); + + // The following also works... + // thisContext._videoFeedInterval = setInterval( + // () => this.getFrame({format: ModalVideoProvider.FORMAT_CANVAS}), + // thisContext._frameCacheTimeout); + + + // thisContext._setupPreview(); + }) + .catch(e => { + log.warn(e); // TODO make common function for this + }); + } + + // _setupPreview () { + // + // // if we haven't already created and started a preview frame render loop, do so + // if (!this._renderPreviewFrame) { + // + // this._renderPreviewFrame = () => { + // clearTimeout(this._renderPreviewTimeout); + // if (!this._renderPreviewFrame) { + // return; + // } + // + // this._renderPreviewTimeout = setTimeout(this._renderPreviewFrame, this._frameCacheTimeout); + // + // const canvas = this.getFrame({format: ModalVideoProvider.FORMAT_CANVAS}); + // + // if (!canvas) { + // // this._skin.clear(); + // return; + // } + // + // // const xOffset = ModalVideoProvider.DIMENSIONS[0] / -2; + // // const yOffset = ModalVideoProvider.DIMENSIONS[1] / 2; + // // this._skin.drawStamp(canvas, xOffset, yOffset); + // // this.runtime.requestRedraw(); + // }; + // + // this._renderPreviewFrame(); + // } + // } + + takeSnapshot () { + clearInterval(this._videoFeedInterval); + + // clearTimeout(this._renderPreviewTimeout); + + return this._canvas.toDataURL('image/png'); + } + + getSnapshot () { + return this._capture; + } + + /** + * 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: this._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; + // } + + /** + * 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 the global + * mirror state (ioDevices.video.mirror) + * @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 = ModalVideoProvider.DIMENSIONS, + // mirror = this.mirror, + // format = ModalVideoProvider.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(); + // 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 === ModalVideoProvider.FORMAT_IMAGE_DATA) { + // formatCache.lastData = context.getImageData(0, 0, width, height); + // } else if (format === ModalVideoProvider.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; + // } + + disableVideo () { + if (this._video) { + if (this._track) this._track.stop(); + this._video.pause(); + this._video.srcObject = null; + } + } +} + +const stageVideoProvider = function (runtime) { + return new StageVideoProvider(runtime); +}; + +export { + stageVideoProvider, + ModalVideoProvider +}; diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js index 040a026b2..ca4ce8773 100644 --- a/src/lib/file-uploader.js +++ b/src/lib/file-uploader.js @@ -77,7 +77,8 @@ const cacheAsset = function (storage, fileName, assetType, dataFormat, data) { /** * Handles loading a costume or a backdrop using the provided, context-relevant information. - * @param {ArrayBuffer} fileData The costume data to load + * @param {ArrayBuffer | string} fileData The costume data to load (this can be an image url + * iff the image is a bitmap) * @param {string} fileType The MIME type of this file * @param {string} costumeName The user-readable name to use for the costume. * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data diff --git a/src/reducers/modals.js b/src/reducers/modals.js index 481c44f29..c373ce796 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -4,6 +4,7 @@ const OPEN_MODAL = 'scratch-gui/modals/OPEN_MODAL'; const CLOSE_MODAL = 'scratch-gui/modals/CLOSE_MODAL'; const MODAL_BACKDROP_LIBRARY = 'backdropLibrary'; +const MODAL_CAMERA_CAPTURE = 'cameraCapture'; const MODAL_COSTUME_LIBRARY = 'costumeLibrary'; const MODAL_EXTENSION_LIBRARY = 'extensionLibrary'; const MODAL_IMPORT_INFO = 'importInfo'; @@ -18,6 +19,7 @@ const MODAL_TIPS_LIBRARY = 'tipsLibrary'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, + [MODAL_CAMERA_CAPTURE]: false, [MODAL_COSTUME_LIBRARY]: false, [MODAL_EXTENSION_LIBRARY]: false, [MODAL_IMPORT_INFO]: false, @@ -60,6 +62,10 @@ const openBackdropLibrary = function () { analytics.pageview('/libraries/backdrops'); return openModal(MODAL_BACKDROP_LIBRARY); }; +const openCameraCapture = function () { + analytics.pageview('/modals/camera'); + return openModal(MODAL_CAMERA_CAPTURE); +}; const openCostumeLibrary = function () { analytics.pageview('/libraries/costumes'); return openModal(MODAL_COSTUME_LIBRARY); @@ -99,6 +105,9 @@ const openTipsLibrary = function () { const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; +const closeCameraCapture = function () { + return closeModal(MODAL_CAMERA_CAPTURE); +}; const closeCostumeLibrary = function () { return closeModal(MODAL_COSTUME_LIBRARY); }; @@ -129,6 +138,7 @@ const closeTipsLibrary = function () { export { reducer as default, openBackdropLibrary, + openCameraCapture, openCostumeLibrary, openExtensionLibrary, openImportInfo, @@ -139,6 +149,7 @@ export { openSoundRecorder, openTipsLibrary, closeBackdropLibrary, + closeCameraCapture, closeCostumeLibrary, closeExtensionLibrary, closeImportInfo, -- GitLab