From e28a595b525f6ff6c472ac3a00b13e455d7cea4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 26 Jul 2024 16:06:11 -0300 Subject: [PATCH 1/2] enhancement(webcam): custom virtual backgrounds --- .../ui/components/video-preview/component.jsx | 128 ++++++++++++++++-- .../virtual-background/component.jsx | 69 ---------- .../virtual-background/service.js | 23 ++++ .../virtual-background/styles.js | 5 +- .../components/video-provider/component.jsx | 4 +- .../ui/services/virtual-background/service.js | 16 +-- 6 files changed, 153 insertions(+), 92 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx index 9f680b6509..1de6943958 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx @@ -5,6 +5,7 @@ import { } from 'react-intl'; import Button from '/imports/ui/components/common/button/component'; import VirtualBgSelector from '/imports/ui/components/video-preview/virtual-background/component'; +import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service'; import logger from '/imports/startup/client/logger'; import browserInfo from '/imports/utils/browserInfo'; import PreviewService from './service'; @@ -19,10 +20,15 @@ import { setSessionVirtualBackgroundInfo, getSessionVirtualBackgroundInfo, isVirtualBackgroundSupported, + clearSessionVirtualBackgroundInfo, + getSessionVirtualBackgroundInfoWithDefault, } from '/imports/ui/services/virtual-background/service'; import Settings from '/imports/ui/services/settings'; import { isVirtualBackgroundsEnabled } from '/imports/ui/services/features'; import Checkbox from '/imports/ui/components/common/checkbox/component'; +import { CustomVirtualBackgroundsContext } from '/imports/ui/components/video-preview/virtual-background/context'; +import Auth from '/imports/ui/services/auth'; +import Users from '/imports/api/users'; const VIEW_STATES = { finding: 'finding', @@ -34,7 +40,9 @@ const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightne const CAMERA_BRIGHTNESS_AVAILABLE = ENABLE_CAMERA_BRIGHTNESS && isVirtualBackgroundSupported(); const propTypes = { - intl: PropTypes.object.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, closeModal: PropTypes.func.isRequired, startSharing: PropTypes.func.isRequired, stopSharing: PropTypes.func.isRequired, @@ -43,6 +51,7 @@ const propTypes = { hasVideoStream: PropTypes.bool.isRequired, webcamDeviceId: PropTypes.string, sharedDevices: PropTypes.arrayOf(PropTypes.string), + cameraAsContent: PropTypes.bool, }; const defaultProps = { @@ -50,6 +59,7 @@ const defaultProps = { camCapReached: true, webcamDeviceId: null, sharedDevices: [], + cameraAsContent: false, }; const intlMessages = defineMessages({ @@ -270,9 +280,44 @@ class VideoPreview extends Component { webcamDeviceId, forceOpen, } = this.props; + const { dispatch, backgrounds } = this.context; this._isMounted = true; + // Set the custom or default virtual background + const webcamBackground = Users.findOne({ + meetingId: Auth.meetingID, + userId: Auth.userID, + }, { + fields: { + webcamBackground: 1, + }, + }); + + const webcamBackgroundURL = webcamBackground?.webcamBackground; + if (webcamBackgroundURL !== '' && !backgrounds.webcamBackgroundURL) { + VirtualBgService.getFileFromUrl(webcamBackgroundURL).then((fetchedWebcamBackground) => { + if (fetchedWebcamBackground) { + const data = URL.createObjectURL(fetchedWebcamBackground); + const uniqueId = 'webcamBackgroundURL'; + const filename = webcamBackgroundURL; + dispatch({ + type: 'update', + background: { + filename, + uniqueId, + data, + lastActivityDate: Date.now(), + custom: true, + sessionOnly: true, + }, + }); + } else { + logger.error('Failed to fetch custom webcam background image. Using fallback image.'); + } + }); + } + if (deviceInfo.hasMediaDevices) { navigator.mediaDevices.enumerateDevices().then((devices) => { VideoService.updateNumberOfDevices(devices); @@ -620,8 +665,54 @@ class VideoPreview extends Component { }); } + async startEffects(deviceId) { + // Brightness and backgrounds are independent of each other, + // handle each one separately. + try { + await this.startCameraBrightness(); + } catch (error) { + logger.warn({ + logCode: 'brightness_effect_error', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, 'Failed to start brightness effect'); + } + + let type; + let name; + let customParams; + + const { backgrounds } = this.context; + const { webcamBackgroundURL } = backgrounds; + const storedBackgroundInfo = getSessionVirtualBackgroundInfo(deviceId); + + if (storedBackgroundInfo) { + type = storedBackgroundInfo.type; + name = storedBackgroundInfo.name; + customParams = storedBackgroundInfo.customParams; + } else if (webcamBackgroundURL) { + const { data, filename } = webcamBackgroundURL; + type = EFFECT_TYPES.IMAGE_TYPE; + name = filename; + customParams = { file: data }; + } + + if (!type) return Promise.resolve(true); + + try { + return this.handleVirtualBgSelected(type, name, customParams); + } catch (error) { + this.handleVirtualBgError(error, type, name); + clearSessionVirtualBackgroundInfo(deviceId); + throw error; + } + } + getCameraStream(deviceId, profile) { const { webcamDeviceId } = this.state; + const { cameraAsContent } = this.props; this.setState({ selectedProfile: profile.id, @@ -635,17 +726,33 @@ class VideoPreview extends Component { // The return of doGUM is an instance of BBBVideoStream (a thin wrapper over a MediaStream) return PreviewService.doGUM(deviceId, profile).then((bbbVideoStream) => { // Late GUM resolve, clean up tracks, stop. - if (!this._isMounted) return this.terminateCameraStream(bbbVideoStream, deviceId); + if (!this._isMounted) { + this.terminateCameraStream(bbbVideoStream, deviceId); + this.cleanupStreamAndVideo(); + return Promise.resolve(false); + } this.currentVideoStream = bbbVideoStream; - this.startCameraBrightness().then(() => { - const { type, name, customParams } = getSessionVirtualBackgroundInfo(deviceId); - this.handleVirtualBgSelected(type, name, customParams).then(() => { - this.setState({ - isStartSharingDisabled: false, - }); + this.updateDeviceId(deviceId); + + if (cameraAsContent) return Promise.resolve(true); + + return this.startEffects(deviceId) + .catch((error) => { + if (this.shouldSkipVideoPreview()) { + throw error; + } + }) + .finally(() => { + if (this._isMounted) { + this.setState({ + isStartSharingDisabled: false, + }); + } else { + this.terminateCameraStream(bbbVideoStream, deviceId); + this.cleanupStreamAndVideo(); + } }); - }); }).catch((error) => { // When video preview is set to skip, we need some way to bubble errors // up to users; so re-throw the error @@ -917,7 +1024,7 @@ class VideoPreview extends Component { const initialVirtualBgState = this.currentVideoStream ? { type: this.currentVideoStream.virtualBgType, name: this.currentVideoStream.virtualBgName - } : getSessionVirtualBackgroundInfo(webcamDeviceId); + } : getSessionVirtualBackgroundInfoWithDefault(webcamDeviceId); return ( ENABLE_UPLOAD && isCustomVirtualBackgroundsEnabled(); -// Function to convert image URL to a File object -async function getFileFromUrl(url) { - try { - const response = await fetch(url, { - credentials: 'omit', - mode: 'cors', - headers: { - Accept: 'image/*', - }, - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const blob = await response.blob(); - const file = new File([blob], 'fetchedWebcamBackground', { type: blob.type }); - return file; - } catch (error) { - logger.error('Fetch error:', error); - return null; - } -} - const VirtualBgSelector = ({ intl, handleVirtualBgSelected, @@ -159,51 +135,6 @@ const VirtualBgSelector = ({ } if (!loaded) loadFromDB(); } - - // Set the custom or default virtual background - const webcamBackground = Users.findOne({ - meetingId: Auth.meetingID, - userId: Auth.userID, - }, { - fields: { - webcamBackground: 1, - }, - }); - - const webcamBackgroundURL = webcamBackground?.webcamBackground; - if (webcamBackgroundURL !== '' && !backgrounds.webcamBackgroundURL) { - getFileFromUrl(webcamBackgroundURL).then((fetchedWebcamBackground) => { - if (fetchedWebcamBackground) { - const data = URL.createObjectURL(fetchedWebcamBackground); - const uniqueId = 'webcamBackgroundURL'; - const filename = webcamBackgroundURL; - dispatch({ - type: 'update', - background: { - filename, - uniqueId, - data, - lastActivityDate: Date.now(), - custom: true, - sessionOnly: true, - }, - }); - handleVirtualBgSelected( - EFFECT_TYPES.IMAGE_TYPE, - webcamBackgroundURL, - { file: data, uniqueId }, - ).then((switched) => { - if (!switched) { - setCurrentVirtualBg({ type: EFFECT_TYPES.NONE_TYPE }); - return; - } - setCurrentVirtualBg({ type: EFFECT_TYPES.IMAGE_TYPE, name: filename }); - }); - } else { - logger.error('Failed to fetch custom webcam background image. Using fallback image.'); - } - }); - } }, []); const _virtualBgSelected = (type, name, index, customParams) => diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/service.js b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/service.js index ce858d9a71..0a4e2da2b4 100644 --- a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/service.js @@ -104,6 +104,28 @@ const update = (background) => { }); }; +// Function to convert image URL to a File object +async function getFileFromUrl(url) { + try { + const response = await fetch(url, { + credentials: 'omit', + mode: 'cors', + headers: { + Accept: 'image/*', + }, + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); + const file = new File([blob], 'fetchedWebcamBackground', { type: blob.type }); + return file; + } catch (error) { + logger.error('Fetch error:', error); + return null; + } +} + export default { load, save, @@ -111,4 +133,5 @@ export default { update, MIME_TYPES_ALLOWED, MAX_FILE_SIZE, + getFileFromUrl, }; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js index ec2db6c2b4..d02d983a26 100644 --- a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js +++ b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js @@ -89,7 +89,8 @@ const ThumbnailButton = styled(Button)` ${({ background }) => background && ` background-image: url(${background}); - background-size: 46px 46px; + background-size: cover; + background-position: center; background-origin: padding-box; &:active { @@ -177,4 +178,4 @@ export default { ButtonRemove, BgCustomButton, SkeletonWrapper, -}; \ No newline at end of file +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 75bae3de66..9d88dd82a3 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -17,7 +17,7 @@ import MediaStreamUtils from '/imports/utils/media-stream-utils'; import BBBVideoStream from '/imports/ui/services/webrtc-base/bbb-video-stream'; import { EFFECT_TYPES, - getSessionVirtualBackgroundInfo, + getSessionVirtualBackgroundInfoWithDefault, } from '/imports/ui/services/virtual-background/service'; import { notify } from '/imports/ui/services/notification'; import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils'; @@ -1049,7 +1049,7 @@ class VideoProvider extends Component { peer.bbbVideoStream.mediaStream, 'video', ); - const { type, name } = getSessionVirtualBackgroundInfo(deviceId); + const { type, name } = getSessionVirtualBackgroundInfoWithDefault(deviceId); this.restoreVirtualBackground(peer.bbbVideoStream, type, name).catch((error) => { this.handleVirtualBgError(error, type, name); diff --git a/bigbluebutton-html5/imports/ui/services/virtual-background/service.js b/bigbluebutton-html5/imports/ui/services/virtual-background/service.js index 7386171f6a..803e32f2e6 100644 --- a/bigbluebutton-html5/imports/ui/services/virtual-background/service.js +++ b/bigbluebutton-html5/imports/ui/services/virtual-background/service.js @@ -73,18 +73,15 @@ const setSessionVirtualBackgroundInfo = ( deviceId, ) => Session.set(`VirtualBackgroundInfo_${deviceId}`, { type, name, customParams }); -const getSessionVirtualBackgroundInfo = (deviceId) => Session.get(`VirtualBackgroundInfo_${deviceId}`) || { +const getSessionVirtualBackgroundInfo = (deviceId) => Session.get(`VirtualBackgroundInfo_${deviceId}`); + +const clearSessionVirtualBackgroundInfo = (deviceId) => Session.set(`VirtualBackgroundInfo_${deviceId}`, null); + +const getSessionVirtualBackgroundInfoWithDefault = (deviceId) => Session.get(`VirtualBackgroundInfo_${deviceId}`) || { type: EFFECT_TYPES.NONE_TYPE, name: '', }; -const getSessionVirtualBackgroundInfoWithDefault = (deviceId) => { - return Session.get(`VirtualBackgroundInfo_${deviceId}`) || { - type: EFFECT_TYPES.BLUR_TYPE, - name: BLUR_FILENAME, - }; -} - const isVirtualBackgroundSupported = () => { return !(deviceInfo.isIos || browserInfo.isSafari); } @@ -110,4 +107,5 @@ export { createVirtualBackgroundStream, getVirtualBackgroundThumbnail, getVirtualBgImagePath, -} + clearSessionVirtualBackgroundInfo, +}; From 565f8a14c7fb07ee282bcd7f1a563f41527f106c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 29 Jul 2024 08:56:51 -0300 Subject: [PATCH 2/2] test: Update virtual background thumbnail image --- .../custom-background-item-Chromium-linux.png | Bin 3758 -> 3434 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bigbluebutton-tests/playwright/webcam/webcam.spec.js-snapshots/custom-background-item-Chromium-linux.png b/bigbluebutton-tests/playwright/webcam/webcam.spec.js-snapshots/custom-background-item-Chromium-linux.png index 2670a576d9db24e0490312174548322f11f16e7f..0fe8e6de0e5a4b4b836101c8135b6e40e9a9b2e8 100644 GIT binary patch delta 3419 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 M07*qoM6N<$g77dRe*gdg