Browse Source

feat(ScreenshotCaptureEffect) Implement.

efficient_tiling
Mihai Uscat 5 years ago
parent
commit
a18ed3a779

+ 4
- 0
conference.js View File

@@ -122,6 +122,7 @@ import { setSharedVideoStatus } from './react/features/shared-video';
122 122
 import { createPresenterEffect } from './react/features/stream-effects/presenter';
123 123
 import { endpointMessageReceived } from './react/features/subtitles';
124 124
 import { createRnnoiseProcessorPromise } from './react/features/rnnoise';
125
+import { toggleScreenshotCaptureEffect } from './react/features/screenshot-capture';
125 126
 
126 127
 const logger = require('jitsi-meet-logger').getLogger(__filename);
127 128
 
@@ -1460,6 +1461,8 @@ export default {
1460 1461
             promise = promise.then(() => this.useVideoStream(null));
1461 1462
         }
1462 1463
 
1464
+        APP.store.dispatch(toggleScreenshotCaptureEffect(false));
1465
+
1463 1466
         return promise.then(
1464 1467
             () => {
1465 1468
                 this.videoSwitchInProgress = false;
@@ -1731,6 +1734,7 @@ export default {
1731 1734
             .then(stream => this.useVideoStream(stream))
1732 1735
             .then(() => {
1733 1736
                 this.videoSwitchInProgress = false;
1737
+                APP.store.dispatch(toggleScreenshotCaptureEffect(true));
1734 1738
                 sendAnalytics(createScreenSharingEvent('started'));
1735 1739
                 logger.log('Screen sharing started');
1736 1740
             })

+ 6
- 1
interface_config.js View File

@@ -188,7 +188,12 @@ var interfaceConfig = {
188 188
      *
189 189
      * Note: this mode is experimental and subject to breakage.
190 190
      */
191
-    AUTO_PIN_LATEST_SCREEN_SHARE: 'remote-only'
191
+    AUTO_PIN_LATEST_SCREEN_SHARE: 'remote-only',
192
+
193
+    /**
194
+     * If we should capture periodic screenshots of the content sharing.
195
+     */
196
+    ENABLE_SCREENSHOT_CAPTURE: false
192 197
 
193 198
     /**
194 199
      * How many columns the tile view can expand to. The respected range is

+ 13
- 0
package-lock.json View File

@@ -13314,6 +13314,14 @@
13314 13314
         "node-modules-regexp": "^1.0.0"
13315 13315
       }
13316 13316
     },
13317
+    "pixelmatch": {
13318
+      "version": "5.1.0",
13319
+      "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.1.0.tgz",
13320
+      "integrity": "sha512-HqtgvuWN12tBzKJf7jYsc38Ha28Q2NYpmBL9WostEGgDHJqbTLkjydZXL1ZHM02ZnB+Dkwlxo87HBY38kMiD6A==",
13321
+      "requires": {
13322
+        "pngjs": "^3.4.0"
13323
+      }
13324
+    },
13317 13325
     "pkg-dir": {
13318 13326
       "version": "2.0.0",
13319 13327
       "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
@@ -13375,6 +13383,11 @@
13375 13383
       "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==",
13376 13384
       "dev": true
13377 13385
     },
13386
+    "pngjs": {
13387
+      "version": "3.4.0",
13388
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
13389
+      "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="
13390
+    },
13378 13391
     "popper.js": {
13379 13392
       "version": "1.14.4",
13380 13393
       "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.4.tgz",

+ 1
- 0
package.json View File

@@ -61,6 +61,7 @@
61 61
     "lodash": "4.17.13",
62 62
     "moment": "2.19.4",
63 63
     "moment-duration-format": "2.2.2",
64
+    "pixelmatch": "5.1.0",
64 65
     "react": "16.9",
65 66
     "react-dom": "16.9",
66 67
     "react-emoji-render": "1.0.0",

+ 20
- 9
react/features/base/tracks/functions.js View File

@@ -1,5 +1,6 @@
1 1
 /* global APP */
2 2
 
3
+import { createScreenshotCaptureEffect } from '../../stream-effects/screenshot-capture';
3 4
 import { getBlurEffect } from '../../blur';
4 5
 import JitsiMeetJS, { JitsiTrackErrors, browser } from '../lib-jitsi-meet';
5 6
 import { MEDIA_TYPE } from '../media';
@@ -101,21 +102,30 @@ export function createLocalTracksF(
101 102
     const constraints = options.constraints
102 103
         ?? state['features/base/config'].constraints;
103 104
 
104
-    // Do not load blur effect if option for ignoring effects is present.
105
-    // This is needed when we are creating a video track for presenter mode.
106
-    const loadEffectsPromise = state['features/blur'].blurEnabled
105
+    const blurPromise = state['features/blur'].blurEnabled
107 106
         ? getBlurEffect()
108
-            .then(blurEffect => [ blurEffect ])
109 107
             .catch(error => {
110 108
                 logger.error('Failed to obtain the blur effect instance with error: ', error);
111 109
 
112
-                return Promise.resolve([]);
110
+                return Promise.resolve();
113 111
             })
114
-        : Promise.resolve([]);
112
+        : Promise.resolve();
113
+    const screenshotCapturePromise = state['features/screenshot-capture'].capturesEnabled
114
+        ? createScreenshotCaptureEffect(state)
115
+            .catch(error => {
116
+                logger.error('Failed to obtain the screenshot capture effect effect instance with error: ', error);
117
+
118
+                return Promise.resolve();
119
+            })
120
+        : Promise.resolve();
121
+    const loadEffectsPromise = Promise.all([ blurPromise, screenshotCapturePromise ]);
115 122
 
116 123
     return (
117
-        loadEffectsPromise.then(effects =>
118
-            JitsiMeetJS.createLocalTracks(
124
+        loadEffectsPromise.then(effectsArray => {
125
+            // Filter any undefined values returned by Promise.resolve().
126
+            const effects = effectsArray.filter(effect => Boolean(effect));
127
+
128
+            return JitsiMeetJS.createLocalTracks(
119 129
                 {
120 130
                     cameraDeviceId,
121 131
                     constraints,
@@ -138,7 +148,8 @@ export function createLocalTracksF(
138 148
                 logger.error('Failed to create local tracks', options.devices, err);
139 149
 
140 150
                 return Promise.reject(err);
141
-            })));
151
+            });
152
+        }));
142 153
 }
143 154
 
144 155
 /**

+ 11
- 0
react/features/screenshot-capture/actionTypes.js View File

@@ -0,0 +1,11 @@
1
+// @flow
2
+
3
+/**
4
+ * Redux action type dispatched in order to toggle screenshot captures.
5
+ *
6
+ * {
7
+ *      type: SET_SCREENSHOT_CAPTURE
8
+ * }
9
+ */
10
+
11
+export const SET_SCREENSHOT_CAPTURE = 'SET_SCREENSHOT_CAPTURE';

+ 52
- 0
react/features/screenshot-capture/actions.js View File

@@ -0,0 +1,52 @@
1
+// @flow
2
+
3
+import { createScreenshotCaptureEffect } from '../stream-effects/screenshot-capture';
4
+import { getLocalVideoTrack } from '../../features/base/tracks';
5
+
6
+import { SET_SCREENSHOT_CAPTURE } from './actionTypes';
7
+
8
+/**
9
+ * Marks the on-off state of screenshot captures.
10
+ *
11
+ * @param {boolean} enabled - Whether to turn screen captures on or off.
12
+ * @returns {{
13
+    *      type: START_SCREENSHOT_CAPTURE,
14
+    *      payload: enabled
15
+    * }}
16
+*/
17
+function setScreenshotCapture(enabled) {
18
+    return {
19
+        type: SET_SCREENSHOT_CAPTURE,
20
+        payload: enabled
21
+    };
22
+}
23
+
24
+/**
25
+* Action that toggles the screenshot captures.
26
+*
27
+* @param {boolean} enabled - Bool that represents the intention to start/stop screenshot captures.
28
+* @returns {Promise}
29
+*/
30
+export function toggleScreenshotCaptureEffect(enabled: boolean) {
31
+    return function(dispatch: (Object) => Object, getState: () => any) {
32
+        const state = getState();
33
+
34
+        if (state['features/screenshot-capture'].capturesEnabled !== enabled) {
35
+            const { jitsiTrack } = getLocalVideoTrack(state['features/base/tracks']);
36
+
37
+            return createScreenshotCaptureEffect(state)
38
+                .then(effect =>
39
+                    jitsiTrack.setEffect(enabled ? effect : undefined)
40
+                        .then(() => {
41
+                            dispatch(setScreenshotCapture(enabled));
42
+                        })
43
+                        .catch(() => {
44
+                            dispatch(setScreenshotCapture(!enabled));
45
+                        })
46
+                )
47
+                .catch(() => dispatch(setScreenshotCapture(false)));
48
+        }
49
+
50
+        return Promise.resolve();
51
+    };
52
+}

+ 3
- 0
react/features/screenshot-capture/index.js View File

@@ -0,0 +1,3 @@
1
+export * from './actions';
2
+
3
+import './reducer';

+ 23
- 0
react/features/screenshot-capture/reducer.js View File

@@ -0,0 +1,23 @@
1
+// @flow
2
+
3
+import { ReducerRegistry } from '../base/redux';
4
+import { PersistenceRegistry } from '../base/storage';
5
+
6
+import { SET_SCREENSHOT_CAPTURE } from './actionTypes';
7
+
8
+PersistenceRegistry.register('features/screnshot-capture', true, {
9
+    capturesEnabled: false
10
+});
11
+
12
+ReducerRegistry.register('features/screenshot-capture', (state = {}, action) => {
13
+    switch (action.type) {
14
+    case SET_SCREENSHOT_CAPTURE: {
15
+        return {
16
+            ...state,
17
+            capturesEnabled: action.payload
18
+        };
19
+    }
20
+    }
21
+
22
+    return state;
23
+});

+ 176
- 0
react/features/stream-effects/screenshot-capture/ScreenshotCaptureEffect.js View File

@@ -0,0 +1,176 @@
1
+// @flow
2
+
3
+import pixelmatch from 'pixelmatch';
4
+
5
+import {
6
+    CLEAR_INTERVAL,
7
+    INTERVAL_TIMEOUT,
8
+    PIXEL_LOWER_BOUND,
9
+    POLL_INTERVAL,
10
+    SET_INTERVAL
11
+} from './constants';
12
+
13
+import { getCurrentConference } from '../../base/conference';
14
+import { processScreenshot } from './processScreenshot';
15
+import { timerWorkerScript } from './worker';
16
+
17
+declare var interfaceConfig: Object;
18
+
19
+/**
20
+ * Effect that wraps {@code MediaStream} adding periodic screenshot captures.
21
+ * Manipulates the original desktop stream and performs custom processing operations, if implemented.
22
+ */
23
+export default class ScreenshotCaptureEffect {
24
+    _state: Object;
25
+    _currentCanvas: HTMLCanvasElement;
26
+    _currentCanvasContext: CanvasRenderingContext2D;
27
+    _videoElement: HTMLVideoElement;
28
+    _handleWorkerAction: Function;
29
+    _initScreenshotCapture: Function;
30
+    _streamWorker: Worker;
31
+    _streamHeight: any;
32
+    _streamWidth: any;
33
+    _storedImageData: Uint8ClampedArray;
34
+
35
+    /**
36
+     * Initializes a new {@code ScreenshotCaptureEffect} instance.
37
+     *
38
+     * @param {Object} state - The redux state.
39
+     */
40
+    constructor(state: Object) {
41
+        this._state = state;
42
+        this._currentCanvas = document.createElement('canvas');
43
+        this._currentCanvasContext = this._currentCanvas.getContext('2d');
44
+        this._videoElement = document.createElement('video');
45
+
46
+        // Bind handlers such that they access the same instance.
47
+        this._handleWorkerAction = this._handleWorkerAction.bind(this);
48
+        this._initScreenshotCapture = this._initScreenshotCapture.bind(this);
49
+        this._streamWorker = new Worker(timerWorkerScript);
50
+        this._streamWorker.onmessage = this._handleWorkerAction;
51
+    }
52
+
53
+    /**
54
+     * Checks if the local track supports this effect.
55
+     *
56
+     * @param {JitsiLocalTrack} jitsiLocalTrack - Targeted local track.
57
+     * @returns {boolean} - Returns true if this effect can run on the specified track, false otherwise.
58
+     */
59
+    isEnabled(jitsiLocalTrack: Object) {
60
+        return (
61
+            interfaceConfig.ENABLE_SCREENSHOT_CAPTURE
62
+            && jitsiLocalTrack.isVideoTrack()
63
+            && jitsiLocalTrack.videoType === 'desktop'
64
+        );
65
+    }
66
+
67
+    /**
68
+     * Starts the screenshot capture event on a loop.
69
+     *
70
+     * @param {MediaStream} stream - The desktop stream from which screenshots are to be sent.
71
+     * @returns {MediaStream} - The same stream, with the interval set.
72
+     */
73
+    startEffect(stream: MediaStream) {
74
+        const desktopTrack = stream.getVideoTracks()[0];
75
+        const { height, width }
76
+            = desktopTrack.getSettings() ?? desktopTrack.getConstraints();
77
+
78
+        this._streamHeight = height;
79
+        this._streamWidth = width;
80
+        this._currentCanvas.height = parseInt(height, 10);
81
+        this._currentCanvas.width = parseInt(width, 10);
82
+        this._videoElement.height = parseInt(height, 10);
83
+        this._videoElement.width = parseInt(width, 10);
84
+        this._videoElement.srcObject = stream;
85
+        this._videoElement.play();
86
+
87
+        // Store first capture for comparisons in {@code this._handleScreenshot}.
88
+        this._videoElement.addEventListener('loadeddata', this._initScreenshotCapture);
89
+
90
+        return stream;
91
+    }
92
+
93
+    /**
94
+     * Stops the ongoing {@code ScreenshotCaptureEffect} by clearing the {@code Worker} interval.
95
+     *
96
+     * @returns {void}
97
+     */
98
+    stopEffect() {
99
+        this._streamWorker.postMessage({ id: CLEAR_INTERVAL });
100
+        this._videoElement.removeEventListener('loadeddata', this._initScreenshotCapture);
101
+    }
102
+
103
+    /**
104
+     * Method that is called as soon as the first frame of the video loads from stream.
105
+     * The method is used to store the {@code ImageData} object from the first frames
106
+     * in order to use it for future comparisons based on which we can process only certain
107
+     * screenshots.
108
+     *
109
+     * @private
110
+     * @returns {void}
111
+     */
112
+    _initScreenshotCapture() {
113
+        const storedCanvas = document.createElement('canvas');
114
+        const storedCanvasContext = storedCanvas.getContext('2d');
115
+
116
+        storedCanvasContext.drawImage(this._videoElement, 0, 0, this._streamWidth, this._streamHeight);
117
+        const { data } = storedCanvasContext.getImageData(0, 0, this._streamWidth, this._streamHeight);
118
+
119
+        this._storedImageData = data;
120
+        this._streamWorker.postMessage({
121
+            id: SET_INTERVAL,
122
+            timeMs: POLL_INTERVAL
123
+        });
124
+    }
125
+
126
+    /**
127
+     * Handler of the {@code EventHandler} message that calls the appropriate method based on the parameter's id.
128
+     *
129
+     * @private
130
+     * @param {EventHandler} message - Message received from the Worker.
131
+     * @returns {void}
132
+     */
133
+    _handleWorkerAction(message: Object) {
134
+        return message.data.id === INTERVAL_TIMEOUT && this._handleScreenshot();
135
+    }
136
+
137
+    /**
138
+     * Method that decides whether an image should be processed based on a preset pixel lower bound.
139
+     *
140
+     * @private
141
+     * @param {integer} nbPixels - The number of pixels of the candidate image.
142
+     * @returns {boolean} - Whether the image should be processed or not.
143
+     */
144
+    _shouldProcessScreenshot(nbPixels: number) {
145
+        return nbPixels >= PIXEL_LOWER_BOUND;
146
+    }
147
+
148
+    /**
149
+     * Screenshot handler.
150
+     *
151
+     * @private
152
+     * @returns {void}
153
+     */
154
+    _handleScreenshot() {
155
+        this._currentCanvasContext.drawImage(this._videoElement, 0, 0, this._streamWidth, this._streamHeight);
156
+        const { data } = this._currentCanvasContext.getImageData(0, 0, this._streamWidth, this._streamHeight);
157
+        const diffPixels = pixelmatch(data, this._storedImageData, null, this._streamWidth, this._streamHeight);
158
+
159
+        if (this._shouldProcessScreenshot(diffPixels)) {
160
+            const conference = getCurrentConference(this._state);
161
+            const sessionId = conference.getMeetingUniqueId();
162
+            const { connection, timeEstablished } = this._state['features/base/connection'];
163
+            const jid = connection.getJid();
164
+            const timeLapseSeconds = timeEstablished && Math.floor((Date.now() - timeEstablished) / 1000);
165
+            const { jwt } = this._state['features/base/jwt'];
166
+
167
+            this._storedImageData = data;
168
+            processScreenshot(this._currentCanvas, {
169
+                jid,
170
+                jwt,
171
+                sessionId,
172
+                timeLapseSeconds
173
+            });
174
+        }
175
+    }
176
+}

+ 42
- 0
react/features/stream-effects/screenshot-capture/constants.js View File

@@ -0,0 +1,42 @@
1
+// @flow
2
+
3
+/**
4
+ * Number of pixels that signal if two images should be considered different.
5
+ */
6
+export const PIXEL_LOWER_BOUND = 100000;
7
+
8
+/**
9
+ * Number of milliseconds that represent how often screenshots should be taken.
10
+ */
11
+export const POLL_INTERVAL = 30000;
12
+
13
+/**
14
+ * SET_INTERVAL constant is used to set interval and it is set in
15
+ * the id property of the request.data property. timeMs property must
16
+ * also be set. request.data example:
17
+ *
18
+ * {
19
+ *      id: SET_INTERVAL,
20
+ *      timeMs: 33
21
+ * }
22
+ */
23
+export const SET_INTERVAL = 1;
24
+
25
+/**
26
+ * CLEAR_INTERVAL constant is used to clear the interval and it is set in
27
+ * the id property of the request.data property.
28
+ *
29
+ * {
30
+ *      id: CLEAR_INTERVAL
31
+ * }
32
+ */
33
+export const CLEAR_INTERVAL = 2;
34
+
35
+/**
36
+ * INTERVAL_TIMEOUT constant is used as response and it is set in the id property.
37
+ *
38
+ * {
39
+ *      id: INTERVAL_TIMEOUT
40
+ * }
41
+ */
42
+export const INTERVAL_TIMEOUT = 3;

+ 19
- 0
react/features/stream-effects/screenshot-capture/index.js View File

@@ -0,0 +1,19 @@
1
+// @flow
2
+
3
+import ScreenshotCaptureEffect from './ScreenshotCaptureEffect';
4
+import { toState } from '../../base/redux';
5
+
6
+/**
7
+ * Creates a new instance of ScreenshotCaptureEffect.
8
+ *
9
+ * @param {Object | Function} stateful - The redux store, state, or
10
+ * {@code getState} function.
11
+ * @returns {Promise<ScreenshotCaptureEffect>}
12
+ */
13
+export function createScreenshotCaptureEffect(stateful: Object | Function) {
14
+    if (!MediaStreamTrack.prototype.getSettings && !MediaStreamTrack.prototype.getConstraints) {
15
+        return Promise.reject(new Error('ScreenshotCaptureEffect not supported!'));
16
+    }
17
+
18
+    return Promise.resolve(new ScreenshotCaptureEffect(toState(stateful)));
19
+}

+ 12
- 0
react/features/stream-effects/screenshot-capture/processScreenshot.js View File

@@ -0,0 +1,12 @@
1
+// @flow
2
+
3
+/**
4
+ * Helper method used to process screenshots captured by the {@code ScreenshotCaptureEffect}.
5
+ *
6
+ * @param {HTMLCanvasElement} canvas - The canvas containing a screenshot to be processed.
7
+ * @param {Object} options - Custom options required for processing.
8
+ * @returns {void}
9
+ */
10
+export function processScreenshot(canvas: HTMLCanvasElement, options: Object) { // eslint-disable-line no-unused-vars
11
+    return;
12
+}

+ 30
- 0
react/features/stream-effects/screenshot-capture/worker.js View File

@@ -0,0 +1,30 @@
1
+// @flow
2
+
3
+import {
4
+    CLEAR_INTERVAL,
5
+    INTERVAL_TIMEOUT,
6
+    SET_INTERVAL
7
+} from './constants';
8
+
9
+const code = `
10
+    var timer;
11
+
12
+    onmessage = function(request) {
13
+        switch (request.data.id) {
14
+        case ${SET_INTERVAL}: {
15
+            timer = setInterval(() => {
16
+                postMessage({ id: ${INTERVAL_TIMEOUT} });
17
+            }, request.data.timeMs);
18
+            break;
19
+        }
20
+        case ${CLEAR_INTERVAL}: {
21
+            if (timer) {
22
+                clearInterval(timer);
23
+            }
24
+            break;
25
+        }
26
+        }
27
+    };
28
+`;
29
+
30
+export const timerWorkerScript = URL.createObjectURL(new Blob([ code ], { type: 'application/javascript' }));

Loading…
Cancel
Save