From 8c0d895cacbd728b43790cbbb10fb9c120e610cd Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 25 Apr 2018 23:09:00 -0400 Subject: [PATCH 01/11] 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 | 16 + 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, 1010 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 => ( + + + + + + + + {props.capture ? + + + + : + + + + } + + +); + +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 000000000..47d09bc7f --- /dev/null +++ b/src/components/camera-modal/icon--back.svg @@ -0,0 +1,16 @@ + + + + re-record + Created with Sketch. + + + + + + + + + + + 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 ( + + ); + } +} + +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 ? ( + + ) : null} ); } @@ -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.} + */ + 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.