diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx
index f3444af1a4a01ef3e577dbb36bb345264e42c79e..7e543617a30cade0d16ef6eaaac34e675c712c55 100644
--- a/src/containers/stage.jsx
+++ b/src/containers/stage.jsx
@@ -6,6 +6,7 @@ import VM from 'scratch-vm';
 import {connect} from 'react-redux';
 
 import {getEventXY} from '../lib/touch-utils';
+import {VideoProvider} from '../lib/camera';
 
 import StageComponent from '../components/stage/stage.jsx';
 
@@ -57,6 +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());
     }
     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 0000000000000000000000000000000000000000..339d59dc2ca3ac4696ce26738e0d16a4129ee133
--- /dev/null
+++ b/src/lib/camera.js
@@ -0,0 +1,377 @@
+import getUserMedia from 'get-user-media-promise';
+import log from './log.js';
+
+// Single Setup For All Video Streams used by the GUI
+// While VideoProvider uses a private _singleSetup
+// property to ensure that each instance of a VideoProvider
+// use the same setup, this ensures that all instances
+// of VideoProviders use a single stream. This way, closing a camera modal
+// does not affect the video on the stage, and a program running and disabling
+// video on the stage will not affect the camera modal's video.
+const requestStack = [];
+const requestVideoStream = videoDesc => {
+    let streamPromise;
+    if (requestStack.length === 0) {
+        streamPromise = getUserMedia({
+            audio: false,
+            video: videoDesc
+        });
+        requestStack.push(streamPromise);
+    } else if (requestStack.length > 0) {
+        streamPromise = requestStack[0];
+        requestStack.push(true);
+    }
+    return streamPromise;
+};
+
+const requestDisableCheck = () => {
+    requestStack.pop();
+    if (requestStack.length > 0) return false;
+    return true;
+};
+
+/**
+ * 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.
+     *
+     * 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) {
+            const disableTrack = requestDisableCheck();
+            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 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 = 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();
+            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.
+     * 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 = 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;
+    }
+}
+
+/**
+ * Video Manager for Camera Modal
+ */
+class ModalVideoManager {
+    constructor (canvas) {
+        this._videoProvider = new VideoProvider();
+        /**
+         * Frame update interval
+         * @type number
+         */
+        this._frameTimeout = 16;
+
+        this._canvas = canvas;
+        // These values are double the stage dimensions so that the resulting
+        // image does not have to get sized down to accomodate double resolution
+        this._canvasWidth = 960; // Double Stage Width
+        this._canvasHeight = 720; // Double Stage Height
+
+    }
+
+    enableVideo () {
+        this._videoProvider.enableVideo().then(() => {
+
+            const ctx = this._canvas.getContext('2d');
+            ctx.scale(-1, 1);
+            ctx.translate(this._canvasWidth * -1, 0);
+
+            this._drawFrames();
+        });
+    }
+
+    _drawFrames () {
+        const video = this._videoProvider.video;
+        this._videoFeedInterval = setInterval(() =>
+            this._canvas.getContext('2d').drawImage(video,
+                // source x, y, width, height
+                0, 0, video.videoWidth, video.videoHeight,
+                // dest x, y, width, height
+                0, 0, this._canvasWidth, this._canvasHeight
+            ), this._frameTimeout);
+    }
+
+    takeSnapshot () {
+        clearInterval(this._videoFeedInterval);
+        return this._canvas.toDataURL('image/png');
+    }
+
+    clearSnapshot () {
+        this._drawFrames();
+    }
+
+    disableVideo () {
+        this._videoProvider.disableVideo();
+    }
+}
+
+export {
+    VideoProvider,
+    ModalVideoManager
+};