Show:

File: app/mixins/video-record.js

  1. import Ember from 'ember';
  2. import { observer } from '@ember/object';
  3. import VideoRecorder from '../services/video-recorder';
  4. import { colorSpecToRgbaArray, isColor } from '../utils/is-color';
  5. import { expFormat } from '../helpers/exp-format';
  6.  
  7. let {
  8. $
  9. } = Ember;
  10.  
  11. /**
  12. * @module exp-player
  13. * @submodule mixins
  14. */
  15.  
  16. /**
  17. *
  18. * Reference for DEVELOPERS of new frames only!
  19. *
  20. * A mixin that can be used to add basic support for video recording across frames
  21. *
  22. * By default, the recorder will be installed when this frame loads, but recording
  23. * will not start automatically. To override either of these settings, set
  24. * the properties `doUseCamera` and/or `startRecordingAutomatically` in the consuming
  25. * frame.
  26. *
  27. * You will also need to set `recorderElement` if the recorder is to be housed other than
  28. * in an element identified by the ID `recorder`.
  29. *
  30. * The properties `recorder`, `videoList`, `stoppedRecording`, `recorderReady`, and
  31. * `videoId` become available to the consuming frame. The recorder object has fields
  32. * that give information about its state: `hasWebCam`, 'hasCamAccess`, `recording`,
  33. * `connected`, and `micChecked` - for details, see services/video-recorder.js. These
  34. * can be accessed from the consuming frame as e.g. `this.get('recorder').get('hasWebCam')`.
  35. *
  36. * If starting recording automatically, the function `onRecordingStarted` will be called
  37. * once recording begins. If you want to do other things at this point, like proceeding
  38. * to a test trial, you can add this hook in your frame.
  39. *
  40. * See 'methods' for the functions you can use on a frame that extends VideoRecord.
  41. *
  42. * Events recorded in a frame that extends VideoRecord will automatically have additional
  43. * fields videoId (video filename), pipeId (temporary filename initially assigned by
  44. * the recording service),
  45. * and streamTime (when in the video they happened, in s).
  46. *
  47. * Setting up the camera is handled in didInsertElement, and making sure recording is
  48. * stopped is handled in willDestroyElement (Ember hooks that fire during the component
  49. * lifecycle). It is very important (in general, but especially when using this mixin)
  50. * that you call `this._super(...arguments);` in any functions where your frame overrides
  51. * hooks like this, so that the mixin's functions get called too!
  52. *
  53. *
  54. * @class Video-record
  55. */
  56.  
  57. /**
  58. * When recorder detects a change in camera access
  59. *
  60. * @event hasCamAccess
  61. * @param {Boolean} hasCamAccess
  62. */
  63.  
  64. /**
  65. * When recorder detects a change in video stream connection status
  66. *
  67. * @event videoStreamConnection
  68. * @param {String} status status of video stream connection, e.g.
  69. * 'NetConnection.Connect.Success' if successful
  70. */
  71.  
  72. /**
  73. * When pausing study, immediately before request to pause webcam recording
  74. *
  75. * @event pauseVideo
  76. */
  77.  
  78. /**
  79. * When unpausing study, immediately before request to resume webcam recording
  80. *
  81. * @event unpauseVideo
  82. */
  83.  
  84. /**
  85. * Just before stopping webcam video capture
  86. *
  87. * @event stoppingCapture
  88. */
  89.  
  90. export default Ember.Mixin.create({
  91.  
  92. /**
  93. * The recorder object, accessible to the consuming frame. Includes properties
  94. * recorder.nWebcams, recorder.hasCamAccess, recorder.micChecked, recorder.connected.
  95. * @property {VideoRecorder} recorder
  96. * @private
  97. */
  98. recorder: null,
  99.  
  100. /**
  101. * A list of all video IDs used in this mixin (a new one is created for each recording).
  102. * Accessible to consuming frame.
  103. * @property {List} videoList
  104. * @private
  105. */
  106. videoList: null,
  107.  
  108. /**
  109. * Whether recording is stopped already, meaning it doesn't need to be re-stopped when
  110. * destroying frame. This should be set to true by the consuming frame when video is
  111. * stopped.
  112. * @property {Boolean} stoppedRecording
  113. * @private
  114. */
  115. stoppedRecording: false,
  116.  
  117. /**
  118. * JQuery string to identify the recorder element.
  119. * @property {String} recorderElement
  120. * @default '#recorder'
  121. * @private
  122. */
  123. recorderElement: '#recorder',
  124.  
  125. /**
  126. * Whether recorder has been set up yet. Automatically set when doing setup.
  127. * Accessible to consuming frame.
  128. * @property {Boolean} recorderReady
  129. * @private
  130. */
  131. recorderReady: false,
  132.  
  133. /**
  134. * Maximum recording length in seconds. Can be overridden by consuming frame.
  135. * @property {Number} maxRecordingLength
  136. * @default 7200
  137. */
  138. maxRecordingLength: 7200,
  139.  
  140. /**
  141. * Maximum time allowed for video upload before proceeding, in seconds.
  142. * Can be overridden by researcher, based on tradeoff between making families wait and
  143. * losing data.
  144. * @property {Number} maxUploadSeconds
  145. * @default 5
  146. */
  147. maxUploadSeconds: 5,
  148.  
  149. /**
  150. * Whether to autosave recordings. Can be overridden by consuming frame.
  151. * TODO: eventually use this to set up non-recording option for previewing
  152. * @property {Number} autosave
  153. * @default 1
  154. * @private
  155. */
  156. autosave: 1,
  157.  
  158. /**
  159. * Whether to do audio-only (vs also video) recording. Can be overridden by consuming frame.
  160. * @property {Number} audioOnly
  161. * @default 0
  162. */
  163. audioOnly: 0,
  164.  
  165. /**
  166. * Whether to use the camera in this frame. Consuming frame should set this property
  167. * to override if needed.
  168. * @property {Boolean} doUseCamera
  169. * @default true
  170. */
  171. doUseCamera: true,
  172.  
  173. /**
  174. * Whether to start recording ASAP (only applies if doUseCamera). Consuming frame
  175. * should set to override if needed.
  176. * @property {Boolean} startRecordingAutomatically
  177. * @default false
  178. */
  179. startRecordingAutomatically: false,
  180.  
  181. /**
  182. * A video ID to use for the current recording. Format is
  183. * `videoStream_<experimentId>_<frameId>_<sessionId>_timestampMS_RRR`
  184. * where RRR are random numeric digits.
  185. *
  186. * @property {String} videoId
  187. * @private
  188. */
  189. videoId: '',
  190.  
  191. /**
  192. * Whether to initially show a message saying to wait until recording starts, covering the entire frame.
  193. * This prevents participants from seeing any stimuli before recording begins. Only used if recording is being
  194. * started immediately.
  195. * @property {Boolean} showWaitForRecordingMessage
  196. * @default true
  197. */
  198. showWaitForRecordingMessage: true,
  199.  
  200. /**
  201. * [Only used if showWaitForRecordingMessage is true] Text to display while waiting for recording to begin.
  202. * @property {Boolean} waitForRecordingMessage
  203. * @default 'Please wait... <br><br> starting webcam recording'
  204. */
  205. waitForRecordingMessage: 'Please wait... <br><br> starting webcam recording',
  206.  
  207. /**
  208. * [Only used if showWaitForRecordingMessage is true] Background color of screen while waiting for recording to
  209. * begin. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
  210. * for acceptable syntax: can use either color names ('blue', 'red', 'green', etc.), or
  211. * rgb hex values (e.g. '#800080' - include the '#'). The text on top of this will be either black or white
  212. * depending on which will have higher contrast.
  213. * @property {Boolean} waitForRecordingMessageColor
  214. * @default 'white'
  215. */
  216. waitForRecordingMessageColor: 'white',
  217.  
  218. /**
  219. * Whether to stop media and hide stimuli with a message saying to wait for video upload when stopping recording.
  220. * Do NOT set this to true if end of recording does not correspond to end of the frame (e.g. during consent or
  221. * observation frames) since it will hide everything upon stopping the recording!
  222. * @property {Boolean} showWaitForUploadMessage
  223. * @default true
  224. */
  225. showWaitForUploadMessage: false,
  226.  
  227. /**
  228. * [Only used if showWaitForUploadMessage is true] Text to display while waiting for recording to begin.
  229. * @property {Boolean} waitForUploadMessage
  230. * @default 'Please wait... <br><br> uploading video'
  231. */
  232. waitForUploadMessage: 'Please wait... <br><br> uploading video',
  233.  
  234. /**
  235. * [Only used if showWaitForUploadMessage is true] Background color of screen while waiting for recording to
  236. * upload. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
  237. * for acceptable syntax: can use either color names ('blue', 'red', 'green', etc.), or
  238. * rgb hex values (e.g. '#800080' - include the '#'). The text on top of this will be either black or white
  239. * depending on which will have higher contrast.
  240. * @property {String} waitForUploadMessageColor
  241. * @default 'white'
  242. */
  243. waitForUploadMessageColor: 'white',
  244.  
  245. /**
  246. * [Only used if showWaitForUploadMessage and/or showWaitForRecordingMessage are true] Image to display along with
  247. * any wait-for-recording or wait-for-upload message. Either waitForWebcamImage or waitForWebcamVideo can be
  248. * specified. This can be either a full URL ('https://...') or just a filename, which will be assumed to be
  249. * inside ``baseDir/img/`` if this frame otherwise supports use of ``baseDir``.
  250. * @property {String} waitForWebcamImage
  251. * @default ''
  252. */
  253. waitForWebcamImage: '',
  254.  
  255. /**
  256. * [Only used if showWaitForUploadMessage and/or showWaitForRecordingMessage are true] Video to display along with
  257. * any wait-for-recording or wait-for-upload message (looping). Either waitForWebcamImage or waitForWebcamVideo can be
  258. * specified. This can be either an array of ``{'src': 'https://...', 'type': '...'}`` objects (e.g. providing both
  259. * webm and mp4 versions at specified URLS) or a single string relative to ``baseDir/<EXT>/`` if this frame otherwise
  260. * supports use of ``baseDir``.
  261. * @property {String} waitForWebcamVideo
  262. * @default ''
  263. */
  264. waitForWebcamVideo: '',
  265.  
  266.  
  267. _generateVideoId() {
  268. return [
  269. 'videoStream',
  270. this.get('experiment.id'),
  271. this.get('id'), // parser enforces that id is composed of a-z, A-Z, -, ., [space]
  272. this.get('session.id'),
  273. +Date.now(), // Timestamp in ms
  274. Math.floor(Math.random() * 1000)
  275. ].join('_');
  276. },
  277.  
  278. /**
  279. * Extend any base time event capture with information about the recorded video
  280. * @method makeTimeEvent
  281. * @param eventName
  282. * @param extra
  283. * @return {Object} Event data object
  284. */
  285. makeTimeEvent(eventName, extra) {
  286. // All frames using this mixin will add streamTime to every server event
  287. let base = this._super(eventName, extra);
  288. Ember.assign(base, {
  289. streamTime: this.get('recorder') ? this.get('recorder').getTime() : null
  290. });
  291. return base;
  292. },
  293.  
  294. /**
  295. * Set up a video recorder instance
  296. * @method setupRecorder
  297. * @param {Node} element A DOM node representing where to mount the recorder
  298. * @return {Promise} A promise representing the result of installing the recorder
  299. */
  300. setupRecorder(element) {
  301. const videoId = this._generateVideoId();
  302. this.set('videoId', videoId);
  303. const recorder = new VideoRecorder({element: element});
  304. const pipeLoc = Ember.getOwner(this).resolveRegistration('config:environment').pipeLoc;
  305. const pipeEnv = Ember.getOwner(this).resolveRegistration('config:environment').pipeEnv;
  306. const installPromise = recorder.install(this.get('videoId'), pipeLoc, pipeEnv,
  307. this.get('maxRecordingLength'), this.get('autosave'), this.get('audioOnly'));
  308.  
  309. // Track specific events for all frames that use VideoRecorder
  310. var _this = this;
  311. recorder.on('onCamAccess', (recId, hasAccess) => { // eslint-disable-line no-unused-vars
  312. if (!(_this.get('isDestroyed') || _this.get('isDestroying'))) {
  313. _this.send('setTimeEvent', 'recorder.hasCamAccess', {
  314. hasCamAccess: hasAccess
  315. });
  316. }
  317. });
  318. recorder.on('onConnectionStatus', (recId, status) => { // eslint-disable-line no-unused-vars
  319. if (!(_this.get('isDestroyed') || _this.get('isDestroying'))) {
  320. _this.send('setTimeEvent', 'videoStreamConnection', {
  321. status: status
  322. });
  323. }
  324. });
  325. this.set('recorder', recorder);
  326. this.send('setTimeEvent', 'setupVideoRecorder', {
  327. videoId: videoId
  328. });
  329. return installPromise;
  330. },
  331.  
  332. /**
  333. * Start recording
  334. * @method startRecorder
  335. * @return Promise Resolves when recording has started
  336. */
  337. startRecorder() {
  338. const recorder = this.get('recorder');
  339. if (recorder) {
  340. return recorder.record().then(() => {
  341. this.send('setTimeEvent', 'startRecording', {
  342. pipeId: recorder.get('pipeVideoName')
  343. });
  344. if (this.get('videoList') == null) {
  345. this.set('videoList', [this.get('videoId')]);
  346. } else {
  347. this.set('videoList', this.get('videoList').concat([this.get('videoId')]));
  348. }
  349. });
  350. } else {
  351. return Ember.RSVP.resolve();
  352. }
  353. },
  354.  
  355. /**
  356. * Stop the recording
  357. * @method stopRecorder
  358. * @return Promise A promise that resolves when upload is complete
  359. */
  360. stopRecorder() {
  361. const recorder = this.get('recorder');
  362. if (recorder && recorder.get('recording')) {
  363. this.send('setTimeEvent', 'stoppingCapture');
  364. if (this.get('showWaitForUploadMessage')) {
  365. // TODO: consider adding progress bar
  366. $( "video audio" ).each(function() {
  367. this.pause();
  368. });
  369.  
  370. let colorSpec = this.get('waitForUploadMessageColor');
  371. if (!isColor(colorSpec)) {
  372. console.warn(`Invalid background color waitForRecordingUploadColor (${colorSpec}) provided; using default instead.`);
  373. colorSpec = 'white';
  374. }
  375. let colorSpecRGBA = colorSpecToRgbaArray(colorSpec);
  376. $('.video-record-mixin-wait-for-video').css('background-color', colorSpec);
  377. $('.video-record-mixin-wait-for-video-text').css('color', (colorSpecRGBA[0] + colorSpecRGBA[1] + colorSpecRGBA[2] > 128 * 3) ? 'black' : 'white');
  378. $('.video-record-mixin-wait-for-video-text').html(`${expFormat(this.get('waitForUploadMessage'))}`);
  379. $('.video-record-mixin-wait-for-video').show();
  380.  
  381. }
  382. return recorder.stop(this.get('maxUploadSeconds') * 1000);
  383. } else {
  384. return Ember.RSVP.reject(1);
  385. }
  386. },
  387.  
  388. /**
  389. * Destroy recorder and stop accessing webcam
  390. * @method destroyRecorder
  391. */
  392. destroyRecorder() {
  393. const recorder = this.get('recorder');
  394. if (recorder) {
  395. if (!(this.get('isDestroyed') || this.get('isDestroying'))) {
  396. this.send('setTimeEvent', 'destroyingRecorder');
  397. }
  398. recorder.destroy();
  399. }
  400. },
  401.  
  402. willDestroyElement() {
  403. var _this = this;
  404. if (_this.get('recorder')) {
  405. window.clearTimeout(_this.get('recorder').get('uploadTimeout'));
  406. if (_this.get('stoppedRecording', true)) {
  407. _this.destroyRecorder();
  408. } else {
  409. _this.stopRecorder().then(() => {
  410. _this.set('stoppedRecording', true);
  411. _this.destroyRecorder();
  412. }, () => {
  413. _this.destroyRecorder();
  414. });
  415. }
  416. }
  417. _this._super(...arguments);
  418. },
  419.  
  420. didReceiveAttrs() {
  421. let assets = this.get('assetsToExpand');
  422. if (assets) {
  423. if (assets.image) {
  424. assets.image.push('waitForUploadImage');
  425. } else {
  426. assets.image = ['waitForUploadImage'];
  427. }
  428. if (assets.video) {
  429. assets.video.push('waitForUploadVideo');
  430. } else {
  431. assets.video = ['waitForUploadVideo'];
  432. }
  433. } else {
  434. this.set('assetsToExpand', {'image': ['waitForUploadImage'], 'video': ['waitForUploadVideo']})
  435. }
  436. console.log(this.get('assetsToExpand'));
  437. this._super(...arguments);
  438. },
  439.  
  440. didInsertElement() {
  441. // Give any active session recorder precedence over individual-frame recording
  442. if (this.get('sessionRecorder') && this.get('session').get('recordingInProgress')) {
  443. console.warn('Recording on this frame was specified, but session recording is already active. Not making frame recording.');
  444. this.set('doUseCamera', false);
  445. }
  446.  
  447. if (this.get('doUseCamera')) {
  448.  
  449. // If showing a wait-for-recording or wait-for-upload message, set it up now.
  450. if ((this.get('showWaitForRecordingMessage') && this.get('startRecordingAutomatically')) || this.get('showWaitForUploadMessage')) {
  451. let $waitForVideoCover = $('<div></div>');
  452. $waitForVideoCover.addClass('video-record-mixin-wait-for-video'); // for easily referencing later to show/hide
  453.  
  454. // Set the background color of the cover
  455. let colorSpec = this.get('waitForRecordingMessageColor');
  456. if (!isColor(colorSpec)) {
  457. console.warn(`Invalid background color waitForRecordingMessageColor (${colorSpec}) provided; using default instead.`);
  458. colorSpec = 'white';
  459. }
  460. let colorSpecRGBA = colorSpecToRgbaArray(colorSpec);
  461. $waitForVideoCover.css('background-color', colorSpec);
  462.  
  463. // Add the image, if any
  464. if (this.get('waitForUploadImage')) {
  465. let imageSource = this.get('waitForUploadImage_parsed') ? this.get('waitForUploadImage_parsed') : this.get('waitForUploadImage');
  466. $waitForVideoCover.append($(`<img src='${imageSource}' class='video-record-mixin-image'>`));
  467. }
  468.  
  469. // Add the video, if any
  470. if (this.get('waitForUploadVideo')) {
  471. let $videoElement = $('<video loop autoplay="autoplay" class="video-record-mixin-image"></video>');
  472. let videoSources = this.get('waitForUploadVideo_parsed') ? this.get('waitForUploadVideo_parsed') : this.get('waitForUploadVideo');
  473. console.log(videoSources);
  474. console.log(this);
  475. $.each(videoSources, function (idx, source) {
  476. $videoElement.append(`<source src=${source.src} type=${source.type}>`);
  477. });
  478. $waitForVideoCover.append($videoElement);
  479. }
  480.  
  481. // Add the text and set its color so it'll be visible against the background
  482. let $waitForVideoText = $(`<div>${expFormat(this.get('waitForRecordingMessage'))}</div>`);
  483. $waitForVideoText.addClass('video-record-mixin-wait-for-video-text');
  484. $waitForVideoText.css('color', (colorSpecRGBA[0] + colorSpecRGBA[1] + colorSpecRGBA[2] > 128 * 3) ? 'black' : 'white');
  485. $waitForVideoCover.append($waitForVideoText);
  486.  
  487. $('div.lookit-frame').append($waitForVideoCover);
  488.  
  489. if (this.get('showWaitForRecordingMessage') && this.get('startRecordingAutomatically')) {
  490. $waitForVideoCover.css('display', 'block');
  491. }
  492. }
  493.  
  494. var _this = this;
  495. this.setupRecorder(this.$(this.get('recorderElement'))).then(() => {
  496. /**
  497. * When video recorder has been installed
  498. *
  499. * @event recorderReady
  500. */
  501. _this.send('setTimeEvent', 'recorderReady');
  502. _this.set('recorderReady', true);
  503. _this.whenPossibleToRecordObserver(); // make sure this fires
  504. });
  505. }
  506. this._super(...arguments);
  507. },
  508.  
  509. /**
  510. * Function called when frame recording is started automatically. Override to do
  511. * frame-specific actions at this point (e.g., beginning a test trial).
  512. *
  513. * @method onRecordingStarted
  514. */
  515. onRecordingStarted() {
  516. },
  517.  
  518. /**
  519. * Observer that starts recording once recorder is ready.
  520. * @method whenPossibleToRecordObserver
  521. */
  522. whenPossibleToRecordObserver: observer('recorder.hasCamAccess', 'recorderReady', function() {
  523. if (this.get('doUseCamera') && this.get('startRecordingAutomatically')) {
  524. var _this = this;
  525. if (this.get('recorder.hasCamAccess') && this.get('recorderReady')) {
  526. this.startRecorder().then(() => {
  527. _this.set('recorderReady', false);
  528. $('.video-record-mixin-wait-for-video').hide();
  529. _this.onRecordingStarted();
  530. });
  531. }
  532. }
  533. }),
  534.  
  535. /**
  536. * Hide the recorder from display. Useful if you would like to keep recording without extra UI elements to
  537. * distract the user.
  538. * @method hideRecorder
  539. */
  540. hideRecorder() {
  541. $(this.get('recorderElement')).parent().addClass('video-recorder-hidden');
  542. },
  543.  
  544. /**
  545. * Show the recorder to the user. Useful if you want to temporarily show a hidden recorder- eg to let the user fix
  546. * a problem with video capture settings
  547. * @method showRecorder
  548. */
  549. showRecorder() {
  550. $(this.get('recorderElement')).parent().removeClass('video-recorder-hidden');
  551. },
  552.  
  553. init() {
  554. this._super(...arguments);
  555. this.set('videoList', []);
  556. }
  557.  
  558. });
  559.