Skip to content

Commit

Permalink
Merge pull request #1092 from kchadha/refactor-video-io
Browse files Browse the repository at this point in the history
Refactor Video IO
  • Loading branch information
kchadha committed Apr 30, 2018
2 parents ad4f565 + 857f77c commit 8e65b3d
Show file tree
Hide file tree
Showing 2 changed files with 30 additions and 214 deletions.
240 changes: 26 additions & 214 deletions src/io/video.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
const log = require('../util/log');

class Video {
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
* @typedef VideoProvider
* @property {Function} enableVideo - Requests camera access from the user, and upon success,
* enables the video feed
* @property {Function} disableVideo - Turns off the video feed
* @property {Function} getFrame - Return frame data from the video feed in
* specified dimensions, format, and mirroring.
*/
this._track = null;

/**
* Stores some canvas/frame data per resolution/mirror states
*/
this._workspace = [];
this.provider = null;

/**
* Id representing a Scratch Renderer skin the video is rendered to for
Expand Down Expand Up @@ -89,6 +64,15 @@ class Video {
return 1;
}

/**
* Set a video provider for this device. A default implementation of
* a video provider can be found in scratch-gui/src/lib/video/video-provider
* @param {VideoProvider} provider - Video provider to use
*/
setProvider (provider) {
this.provider = provider;
}

/**
* Request video be enabled. Sets up video, creates video skin and enables preview.
*
Expand All @@ -97,39 +81,18 @@ class Video {
* @return {Promise.<Video>} resolves a promise to this IO device when video is ready.
*/
enableVideo () {
this.enabled = true;
return this._setupVideo();
if (!this.provider) return null;
return this.provider.enableVideo().then(() => this._setupPreview());
}

/**
* Disable video stream (turn video off)
* @return {void}
*/
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;
}
this._disablePreview();
if (!this.provider) return null;
this.provider.disableVideo();
}

/**
Expand All @@ -151,59 +114,8 @@ class Video {
format = Video.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 === Video.FORMAT_IMAGE_DATA) {
formatCache.lastData = context.getImageData(0, 0, width, height);
} else if (format === Video.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;
if (this.provider) return this.provider.getFrame({dimensions, mirror, format, cacheTimeout});
return null;
}

/**
Expand All @@ -217,62 +129,6 @@ class Video {
}
}

/**
* 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 = 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();
Expand Down Expand Up @@ -331,53 +187,9 @@ class 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;
}

/**
* 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;
if (this.provider) return this.provider.videoReady;
return false;
}

}


Expand Down
4 changes: 4 additions & 0 deletions src/virtual-machine.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ class VirtualMachine extends EventEmitter {
}
}

setVideoProvider (videoProvider) {
this.runtime.ioDevices.video.setProvider(videoProvider);
}

/**
* Load a Scratch project from a .sb, .sb2, .sb3 or json string.
* @param {string | object} input A json string, object, or ArrayBuffer representing the project to load.
Expand Down

0 comments on commit 8e65b3d

Please sign in to comment.