Compare commits

...

10 Commits

Author SHA1 Message Date
Jaya Allamsetty
bd935af7ef test(virtual-background): add V2 e2e tests for studio light, tier override and captureStream
Add VirtualBackgroundDialog page object methods for studio light presets.
Add virtualBackgroundV2.spec.ts with three suites:
- V2 engine: blur, studio light presets, cross-type switching, cancel,
  persistence, rapid switching
- V2 + low tier override: forces TFLite WASM backend via tierOverride
- V2 + captureStream fallback: forces captureStream path via
  useInsertableStreams=false

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:22:33 -04:00
Jaya Allamsetty
1679c4f56a fix(virtual-background): address PR review issues
- Remove duplicate error notification in V2 factory -- let the error
  propagate to toggleBackgroundEffect which already handles it
- Fix worker message listener leak in stopEffect() by tracking and
  removing the pending inference handler before resolving
- Release WebGL context in WebGLCompositor.dispose() via loseContext()
  to avoid hitting the browser ~16 context limit
- Rename _ortSkipCounter to _inferenceSkipCounter since it applies to
  all backends, not just ORT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
ed707190e3 fix(virtual-background): rename ortSkipStride to inferenceStride throughout 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
cc220c6058 address review feedback 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
deb687f3ef fix(virtual-background): use tier-specific edge thresholds and fix stale config docs.
Introduce per-tier defaults: LOW keeps 0.48, MEDIUM/HIGH use 0.28. Config overrides (edgeLow/edgeHigh) still take precedence.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
b64c882b0c fix(virtual0background) Uses selfie_segmenter.tflite model for LOW tier instead of the ML Kit model.
This model has faster inference and better segmentation quality on low end devices.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
5049a5664c fix(virtual-background): replace ORT PP-HumanSeg with ML Kit TFLite for LOW tier and add GPU→TFLite fallback 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
25e9d36a64 fix(virtual-background): improve edge quality, fix deployment issues and studio light
- Raise DEFAULT_EDGE_LOW 0.20→0.30 to reject low-confidence background
    detections (wall decorations, chair backs) without clipping hair edges
  - Add camera-space edge detection (cameraEdgeStrength) in VB shader to
    snap mask at high-contrast boundaries; halve maskBlurRadius target for
    tighter feathering
  - Remove studio light radial focus falloff (hardcoded focusCenter at
    (0.5, 0.35) put the spotlight above the face); effect now applies
    uniformly within the person mask
  - Tune Spotlight preset: reduce warmth and tighten to avoid background bleed
  - Replace dynamic import of JitsiStreamBackgroundEffectV2 with static
    import to prevent webpack chunk splitting and ChunkLoadError on deploy
  - Normalize getBaseUrl() trailing slash to fix malformed worker/model URLs
  - Add vb-inference-worker.min.js to Makefile deploy-appbundle target
  - Move ORT WASM files from libs/ort/ subdir directly into libs/.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
d93dc6870d feat(virtual-background) Adds studio light presets with background dimming and tuned shader parameters.
> - Natural: minimal enhancement, nearly transparent processing
> - Spotlight: face brightly lit against a 40% dimmed background with warm tone
> - Soft Focus: beauty/glamour filter with skin smoothing, glow bloom, and reduced contrast
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
88c3b9c974 feat(virtual-background) Adds v2 engine with worker-based inference, insertable streams and device tier detection.
Introduces JitsiStreamBackgroundEffectV2 alongside the existing V1 effect, opt-in via
  config.virtualBackground.enableV2.

- VBInferenceWorker: all inference runs in a dedicated Worker thread — MEDIUM/HIGH tiers
  use TF.js body-segmentation on an OffscreenCanvas WebGL/WebGPU context (immune to
  Chrome's tab-visibility GPU throttle); LOW tier uses onnxruntime-web@1.17.3 with
  PP-HumanSeg FP32 192×192 WASM
- Insertable streams path: MediaStreamTrackProcessor/Generator as the default frame driver;
    captureStream + rVFC/keepalive-Worker used as fallback
- DeviceTierDetector: localStorage hardware-tier caching so the WebGPU/WebGL probe runs once
- WebGLCompositor: add Gaussian blur kernel in the shader; remove dead methods and textures
- CPU-side EMA mask smoothing replaces GPU ping-pong temporal blending
- ORT frame skipping via ortSkipStride (default 2, ~25fps pipeline at ~12Hz mask rate)
- VideoFrame GC fix: stopEffect() cancels the IS reader immediately to flush buffered frames
- initPromise exposed for async-init error propagation in toggleBackgroundEffect and createLocalTracksF
- requiresPostConstructionApplication getter to safely apply IS-path effects post-construction
- Benchmark logging gated on config.virtualBackground.testMode
- Adds onnxruntime-web@1.17.3, vb-inference-worker webpack entry, ORT/model Makefile deploy targets.
2026-04-09 17:21:20 -04:00
24 changed files with 4089 additions and 104 deletions

View File

@@ -9,6 +9,7 @@ EXCALIDRAW_DIR = node_modules/@jitsi/excalidraw/dist/prod
EXCALIDRAW_DIR_DEV = node_modules/@jitsi/excalidraw/dist/dev
TFLITE_WASM = react/features/stream-effects/virtual-background/vendor/tflite
MEET_MODELS_DIR = react/features/stream-effects/virtual-background/vendor/models
MEDIAPIPE_SEGMENTATION_DIR = node_modules/@mediapipe/selfie_segmentation
FACE_MODELS_DIR = node_modules/@vladmandic/human-models/models
NODE_SASS = ./node_modules/.bin/sass
NPM = npm
@@ -34,7 +35,7 @@ clean:
rm -fr $(BUILD_DIR)
.NOTPARALLEL:
deploy: deploy-init deploy-appbundle deploy-rnnoise-binary deploy-excalidraw deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-css deploy-local deploy-face-landmarks
deploy: deploy-init deploy-appbundle deploy-rnnoise-binary deploy-excalidraw deploy-tflite deploy-meet-models deploy-mediapipe-segmentation deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-css deploy-local deploy-face-landmarks
deploy-init:
rm -fr $(DEPLOY_DIR)
@@ -54,6 +55,8 @@ deploy-appbundle:
$(BUILD_DIR)/noise-suppressor-worklet.min.js.map \
$(BUILD_DIR)/screenshot-capture-worker.min.js \
$(BUILD_DIR)/screenshot-capture-worker.min.js.map \
$(BUILD_DIR)/vb-inference-worker.min.js \
$(BUILD_DIR)/vb-inference-worker.min.js.map \
$(DEPLOY_DIR)
cp \
$(BUILD_DIR)/close3.min.js \
@@ -96,9 +99,16 @@ deploy-excalidraw-dev:
deploy-meet-models:
cp \
$(MEET_MODELS_DIR)/*.tflite \
$(MEET_MODELS_DIR)/selfie_segmentation_landscape.tflite \
$(MEET_MODELS_DIR)/selfie_segmenter.tflite \
$(DEPLOY_DIR)
deploy-mediapipe-segmentation:
mkdir -p $(DEPLOY_DIR)/mediapipe-segmentation
cp \
$(MEDIAPIPE_SEGMENTATION_DIR)/selfie_segmentation* \
$(DEPLOY_DIR)/mediapipe-segmentation
deploy-face-landmarks:
cp \
$(FACE_MODELS_DIR)/blazeface-front.bin \
@@ -116,7 +126,7 @@ deploy-local:
([ ! -x deploy-local.sh ] || ./deploy-local.sh)
.NOTPARALLEL:
dev: deploy-init deploy-css deploy-rnnoise-binary deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-excalidraw-dev deploy-face-landmarks
dev: deploy-init deploy-css deploy-rnnoise-binary deploy-tflite deploy-meet-models deploy-mediapipe-segmentation deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-excalidraw-dev deploy-face-landmarks
$(WEBPACK_DEV_SERVER)
source-package: compile deploy

View File

@@ -1499,6 +1499,56 @@ var config = {
// Sets the background transparency level. '0' is fully transparent, '1' is opaque.
// backgroundAlpha: 1,
// Virtual background V2 options (MediaPipe body-segmentation + WebGL compositor).
// All fields are optional; omitting a field uses the default/auto-detected value.
virtualBackground: {
// Enable the V2 processing engine. When false (default), the legacy
// TFLite WASM engine (V1) is used. Set to true to opt in to V2.
// enableV2: false,
// Force a specific device tier regardless of what the browser supports.
// Useful for testing lower-tier behaviour on high-end hardware.
// Values: 'high' | 'medium' | 'low' — null means auto-detect (default).
// tierOverride: null,
// Override the segmentation canvas dimensions (pixels). Applies to MEDIUM and HIGH
// tiers only (TF.js input canvas). LOW tier (TFLite) always runs at 256×256,
// fixed by the selfie_segmenter model and not affected by this setting.
// segmentationWidth: null, // auto: 512 (high) / 384 (medium)
// segmentationHeight: null, // auto: 288 (high) / 216 (medium)
// Override the target frame rate for the effect.
// targetFps: null, // auto: 30 (all tiers)
// Temporal mask blend ratio (0-1). Higher = smoother motion, slower to respond
// to fast movement. 0 = raw mask each frame (no temporal smoothing).
// temporalBlendRatio: 0.75,
// Smoothstep edge thresholds for the WebGL compositor (0-1).
// Pixels with segmentation confidence below edgeLow are fully transparent;
// above edgeHigh they are fully opaque; between the two they feather.
// Defaults are tier-specific:
// LOW tier (TFLite): edgeLow = 0.48, edgeHigh = 0.65
// MEDIUM/HIGH (TF.js): edgeLow = 0.28, edgeHigh = 0.65
// Lower edgeLow = more hair retained at the cost of slight background bleed.
// Higher edgeHigh = harder edge transition.
// edgeLow: 0.28,
// edgeHigh: 0.65,
// Insertable Streams (MediaStreamTrackProcessor/Generator) is used by default
// when available. It reduces latency by ~1-2 frames and eliminates the keepalive
// Web Worker. Set to false to force the legacy captureStream path instead.
// useInsertableStreams: false,
// LOW tier (TFLite) inference stride. Inference is skipped on alternate frames;
// skipped frames reuse the previous mask. Higher values = lower CPU usage at the
// cost of reduced mask update frequency. Set to 1 to run inference every frame.
// 1 = every frame (24 fps mask updates, ~37 ms slack per frame) ← default
// 2 = every 2 frames (12 fps mask updates, ~74 ms slack per frame)
// inferenceStride: 1,
},
// The URL of the moderated rooms microservice, if available. If it
// is present, a link to the service will be rendered on the welcome page,
// otherwise the app doesn't render it.

10
globals.d.ts vendored
View File

@@ -18,6 +18,16 @@ declare global {
JITSI_MEET_LITE_SDK?: boolean;
interfaceConfig?: any;
JitsiMeetJS?: any;
MediaStreamTrackGenerator: {
new(options: { kind: string }): MediaStreamTrack & {
writable: WritableStream<VideoFrame>;
};
};
MediaStreamTrackProcessor: {
new(options: { track: MediaStreamTrack; maxBufferSize?: number }): {
readable: ReadableStream<VideoFrame>;
};
};
PressureObserver?: any;
PressureRecord?: any;
ReactNativeWebView?: any;

View File

@@ -1631,6 +1631,10 @@
"pleaseWait": "Please wait…",
"removeBackground": "Remove background",
"slightBlur": "Half Blur",
"studioLightNatural": "Natural",
"studioLightSoftFocus": "Soft Focus",
"studioLightSpotlight": "Spotlight",
"studioLightTitle": "Studio Light",
"title": "Virtual backgrounds",
"uploadedImage": "Uploaded image {{index}}",
"webAssemblyWarning": "WebAssembly not supported",

View File

@@ -128,7 +128,11 @@ import { isAudioMuteButtonDisabled } from '../../react/features/toolbox/function
import { setTileView, toggleTileView } from '../../react/features/video-layout/actions.any';
import { muteAllParticipants, muteRemote } from '../../react/features/video-menu/actions';
import { setVideoQuality } from '../../react/features/video-quality/actions';
import { toggleBackgroundEffect, toggleBlurredBackgroundEffect } from '../../react/features/virtual-background/actions';
import {
toggleBackgroundEffect,
toggleBlurredBackgroundEffect,
toggleStudioLightEffect
} from '../../react/features/virtual-background/actions';
import { VIRTUAL_BACKGROUND_TYPE } from '../../react/features/virtual-background/constants';
import { toggleWhiteboard } from '../../react/features/whiteboard/actions.web';
import { getJitsiMeetTransport } from '../transport';
@@ -924,6 +928,12 @@ function initCommands() {
virtualSource: backgroundImage
}, jitsiTrack));
},
'set-studio-light': (enabled, preset) => {
const tracks = APP.store.getState()['features/base/tracks'];
const jitsiTrack = getLocalVideoTrack(tracks)?.jitsiTrack;
APP.store.dispatch(toggleStudioLightEffect(jitsiTrack, enabled, preset));
},
'show-pip': () => {
APP.store.dispatch(showPiP());
},

275
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@jitsi/logger": "2.1.1",
"@jitsi/rnnoise-wasm": "0.2.1",
"@matrix-org/olm": "3.2.15",
"@mediapipe/selfie_segmentation": "^0.1.1675465747",
"@microsoft/microsoft-graph-client": "3.0.1",
"@mui/material": "5.12.1",
"@react-native-async-storage/async-storage": "1.23.1",
@@ -38,8 +39,12 @@
"@sayem314/react-native-keep-awake": "1.3.1",
"@stomp/stompjs": "7.0.0",
"@svgr/webpack": "6.3.1",
"@tensorflow/tfjs-backend-wasm": "3.13.0",
"@tensorflow/tfjs-core": "3.13.0",
"@tensorflow-models/body-segmentation": "^1.0.2",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@tensorflow/tfjs-backend-webgpu": "^4.22.0",
"@tensorflow/tfjs-converter": "^4.22.0",
"@tensorflow/tfjs-core": "^4.22.0",
"@vladmandic/human": "2.6.5",
"@vladmandic/human-models": "2.5.9",
"@xmldom/xmldom": "0.8.7",
@@ -4868,7 +4873,8 @@
"node_modules/@jitsi/precall-test": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@jitsi/precall-test/-/precall-test-1.0.6.tgz",
"integrity": "sha512-L/gnRjnFxpQqvfILXeFoVTHEtwL01zoSKAIqMVxMmaD0ahozML8uztlxRPlYSbKrT31j3Cm1ss9KdSNeO3f9FQ=="
"integrity": "sha512-L/gnRjnFxpQqvfILXeFoVTHEtwL01zoSKAIqMVxMmaD0ahozML8uztlxRPlYSbKrT31j3Cm1ss9KdSNeO3f9FQ==",
"license": "Apache-2.0"
},
"node_modules/@jitsi/rnnoise-wasm": {
"version": "0.2.1",
@@ -5014,6 +5020,12 @@
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/selfie_segmentation": {
"version": "0.1.1675465747",
"resolved": "https://registry.npmjs.org/@mediapipe/selfie_segmentation/-/selfie_segmentation-0.1.1675465747.tgz",
"integrity": "sha512-IxYxNhwE5VwOm52L1yoFWYLP7q9Pd+NJjzOC5tlepfvEGaY3o9hslhUrx9BgseqdfZtKSDtd/4NfCSMjNzQalA==",
"license": "Apache-2.0"
},
"node_modules/@microsoft/microsoft-graph-client": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.1.tgz",
@@ -8512,59 +8524,118 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.13.0.tgz",
"integrity": "sha512-POmzUoAP8HooYYTZ72O1ZYkpVZB0f+8PeAkbTxIG0oahcJccj6a0Vovp1A6xWKfljUoPlJb3jWVC++S603ZL8w==",
"node_modules/@tensorflow-models/body-segmentation": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@tensorflow-models/body-segmentation/-/body-segmentation-1.0.2.tgz",
"integrity": "sha512-sbPiL8wpqfKqh01of6qguZzU9yLWOQDTwiPEIHFPA/EAjz5T51LKHKIySll+mmteRo/TxNED8pxd9VJ6q2r7kg==",
"license": "Apache-2.0",
"dependencies": {
"@types/seedrandom": "2.4.27",
"seedrandom": "2.4.3"
"rimraf": "^3.0.2"
},
"peerDependencies": {
"@mediapipe/selfie_segmentation": "~0.1.0",
"@tensorflow/tfjs-backend-webgl": "^4.9.0",
"@tensorflow/tfjs-converter": "^4.9.0",
"@tensorflow/tfjs-core": "^4.9.0"
}
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"license": "Apache-2.0",
"dependencies": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "3.13.0"
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-wasm": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-3.13.0.tgz",
"integrity": "sha512-h5kNS4xvljoySzfcFwqbdFB6QZGR06IA9/Xq/PjBeZt18XEoJGqKHbOCYupmUlr5pxo/gnXzPhAC2h4SfZXPXw==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-4.22.0.tgz",
"integrity": "sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "3.13.0",
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/emscripten": "~0.0.34"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "3.13.0"
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl/node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT"
},
"node_modules/@tensorflow/tfjs-backend-webgpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgpu/-/tfjs-backend-webgpu-4.22.0.tgz",
"integrity": "sha512-lvIc7Af4Tl2BCdYp43iQmSCRq3asaKT0q2xaErphXiUZ+jqeB0bQa0ZvQys1Xatvto0U4/c90DVsHPfvkn5ftg==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-core": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-3.13.0.tgz",
"integrity": "sha512-18qBEVIB/4u2OUK9nA5P1XT3e3LyarElD1UKNSNDpnMLxhLTUVZaCR71eHJcpl9wP2Q0cciaTJCTpJdPv1tNDQ==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"license": "Apache-2.0",
"dependencies": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "2.4.27",
"@types/webgl-ext": "0.0.30",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "2.4.3"
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
}
},
"node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
},
"node_modules/@testrtc/watchrtc-sdk": {
"version": "1.38.2",
"resolved": "https://registry.npmjs.org/@testrtc/watchrtc-sdk/-/watchrtc-sdk-1.38.2.tgz",
"integrity": "sha512-IlusCskjqgTrroKCr2DQCviIZYe7J3arVUpoGtXc2MiYBeQ5sEs2Yzo6v07T3rDcN71BVBTZ1hXMZlZuqnKiyA=="
"integrity": "sha512-IlusCskjqgTrroKCr2DQCviIZYe7J3arVUpoGtXc2MiYBeQ5sEs2Yzo6v07T3rDcN71BVBTZ1hXMZlZuqnKiyA==",
"license": "ISC"
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
@@ -8937,8 +9008,7 @@
"node_modules/@types/offscreencanvas": {
"version": "2019.7.2",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.2.tgz",
"integrity": "sha512-ujCjOxeA07IbEBQYAkoOI+XFw5sT3nhWJ/xZfPR6reJppDG7iPQPZacQiLTtWH1b3a2NYXWlxvYqa40y/LAixQ==",
"dev": true
"integrity": "sha512-ujCjOxeA07IbEBQYAkoOI+XFw5sT3nhWJ/xZfPR6reJppDG7iPQPZacQiLTtWH1b3a2NYXWlxvYqa40y/LAixQ=="
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@@ -9090,9 +9160,10 @@
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
},
"node_modules/@types/seedrandom": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
"integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.4",
@@ -9185,11 +9256,6 @@
"integrity": "sha512-eTQRkPd2JukZfS9+kRtrBAaTCCb6waGh5X8BJHmH1MiVQPLMYwm4+EvhwFfOo9SDna15o9dFAwmWwN6r/YM53A==",
"dev": true
},
"node_modules/@types/webgl-ext": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
},
"node_modules/@types/webrtc": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/@types/webrtc/-/webrtc-0.0.32.tgz",
@@ -10359,6 +10425,12 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==",
"license": "BSD-3-Clause"
},
"node_modules/@webpack-cli/configtest": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz",
@@ -11214,7 +11286,8 @@
"node_modules/async-es": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/async-es/-/async-es-3.2.4.tgz",
"integrity": "sha512-GFHAAfmW7GzEiHFR6DvE5WMm6+js9pb+RLm+m1UpsyMX+I4j/R4QVw2Te664q+fvDVOz7Y0bORPDNvQS7BJ3Hw=="
"integrity": "sha512-GFHAAfmW7GzEiHFR6DvE5WMm6+js9pb+RLm+m1UpsyMX+I4j/R4QVw2Te664q+fvDVOz7Y0bORPDNvQS7BJ3Hw==",
"license": "MIT"
},
"node_modules/async-exit-hook": {
"version": "2.0.1",
@@ -26447,14 +26520,16 @@
}
},
"node_modules/sdp": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.0.3.tgz",
"integrity": "sha512-8EkfckS+XZQaPLyChu4ey7PghrdcraCVNpJe2Gfdi2ON1ylQ7OasuKX+b37R9slnRChwIAiQgt+oj8xXGD8x+A=="
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.3.0.tgz",
"integrity": "sha512-zR0e9ciWFezeaKLLpWCrOCiYmGIQN9jfO5Ayfs7m5k2/g9b2MEEIvQ/TTmymm167zozTNYSQoLGKDihMoTWkkw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
@@ -26465,9 +26540,10 @@
"integrity": "sha512-cF92Op90//vEpHphRx25rttJGXIgxcTB1WR5y0ODQhN7O4d0lSEOp5+l3sQDx0aAZ2MfXCqFEb/rG/3ghvVDIQ=="
},
"node_modules/seedrandom": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
"integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/select-hose": {
"version": "2.0.0",
@@ -29459,6 +29535,7 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.1.1.tgz",
"integrity": "sha512-1yXevP7TeZGmklEXkvQVrZp3fOSJlLeXNGCA7NovQokxgP3/e2T3EVGL0eKU87S9vKppWjvRWqnJeSANEspOBg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.0.2"
},
@@ -33194,6 +33271,11 @@
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="
},
"@mediapipe/selfie_segmentation": {
"version": "0.1.1675465747",
"resolved": "https://registry.npmjs.org/@mediapipe/selfie_segmentation/-/selfie_segmentation-0.1.1675465747.tgz",
"integrity": "sha512-IxYxNhwE5VwOm52L1yoFWYLP7q9Pd+NJjzOC5tlepfvEGaY3o9hslhUrx9BgseqdfZtKSDtd/4NfCSMjNzQalA=="
},
"@microsoft/microsoft-graph-client": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.1.tgz",
@@ -35416,36 +35498,41 @@
"@svgr/plugin-svgo": "^6.3.1"
}
},
"@tensorflow/tfjs-backend-cpu": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.13.0.tgz",
"integrity": "sha512-POmzUoAP8HooYYTZ72O1ZYkpVZB0f+8PeAkbTxIG0oahcJccj6a0Vovp1A6xWKfljUoPlJb3jWVC++S603ZL8w==",
"@tensorflow-models/body-segmentation": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@tensorflow-models/body-segmentation/-/body-segmentation-1.0.2.tgz",
"integrity": "sha512-sbPiL8wpqfKqh01of6qguZzU9yLWOQDTwiPEIHFPA/EAjz5T51LKHKIySll+mmteRo/TxNED8pxd9VJ6q2r7kg==",
"requires": {
"@types/seedrandom": "2.4.27",
"seedrandom": "2.4.3"
"rimraf": "^3.0.2"
}
},
"@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"requires": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
}
},
"@tensorflow/tfjs-backend-wasm": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-3.13.0.tgz",
"integrity": "sha512-h5kNS4xvljoySzfcFwqbdFB6QZGR06IA9/Xq/PjBeZt18XEoJGqKHbOCYupmUlr5pxo/gnXzPhAC2h4SfZXPXw==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-4.22.0.tgz",
"integrity": "sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==",
"requires": {
"@tensorflow/tfjs-backend-cpu": "3.13.0",
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/emscripten": "~0.0.34"
}
},
"@tensorflow/tfjs-core": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-3.13.0.tgz",
"integrity": "sha512-18qBEVIB/4u2OUK9nA5P1XT3e3LyarElD1UKNSNDpnMLxhLTUVZaCR71eHJcpl9wP2Q0cciaTJCTpJdPv1tNDQ==",
"@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"requires": {
"@types/long": "^4.0.1",
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "2.4.27",
"@types/webgl-ext": "0.0.30",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "2.4.3"
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"dependencies": {
"@types/offscreencanvas": {
@@ -35455,6 +35542,33 @@
}
}
},
"@tensorflow/tfjs-backend-webgpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgpu/-/tfjs-backend-webgpu-4.22.0.tgz",
"integrity": "sha512-lvIc7Af4Tl2BCdYp43iQmSCRq3asaKT0q2xaErphXiUZ+jqeB0bQa0ZvQys1Xatvto0U4/c90DVsHPfvkn5ftg==",
"requires": {
"@tensorflow/tfjs-backend-cpu": "4.22.0"
}
},
"@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="
},
"@tensorflow/tfjs-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"requires": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "^3.0.5"
}
},
"@testrtc/watchrtc-sdk": {
"version": "1.38.2",
"resolved": "https://registry.npmjs.org/@testrtc/watchrtc-sdk/-/watchrtc-sdk-1.38.2.tgz",
@@ -35813,8 +35927,7 @@
"@types/offscreencanvas": {
"version": "2019.7.2",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.2.tgz",
"integrity": "sha512-ujCjOxeA07IbEBQYAkoOI+XFw5sT3nhWJ/xZfPR6reJppDG7iPQPZacQiLTtWH1b3a2NYXWlxvYqa40y/LAixQ==",
"dev": true
"integrity": "sha512-ujCjOxeA07IbEBQYAkoOI+XFw5sT3nhWJ/xZfPR6reJppDG7iPQPZacQiLTtWH1b3a2NYXWlxvYqa40y/LAixQ=="
},
"@types/parse-json": {
"version": "4.0.0",
@@ -35963,9 +36076,9 @@
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
},
"@types/seedrandom": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
"integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="
},
"@types/send": {
"version": "0.17.4",
@@ -36055,11 +36168,6 @@
"integrity": "sha512-eTQRkPd2JukZfS9+kRtrBAaTCCb6waGh5X8BJHmH1MiVQPLMYwm4+EvhwFfOo9SDna15o9dFAwmWwN6r/YM53A==",
"dev": true
},
"@types/webgl-ext": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
},
"@types/webrtc": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/@types/webrtc/-/webrtc-0.0.32.tgz",
@@ -36868,6 +36976,11 @@
"@xtuc/long": "4.2.2"
}
},
"@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="
},
"@webpack-cli/configtest": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz",
@@ -47852,9 +47965,9 @@
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="
},
"sdp": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.0.3.tgz",
"integrity": "sha512-8EkfckS+XZQaPLyChu4ey7PghrdcraCVNpJe2Gfdi2ON1ylQ7OasuKX+b37R9slnRChwIAiQgt+oj8xXGD8x+A=="
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="
},
"sdp-transform": {
"version": "2.3.0",
@@ -47867,9 +47980,9 @@
"integrity": "sha512-cF92Op90//vEpHphRx25rttJGXIgxcTB1WR5y0ODQhN7O4d0lSEOp5+l3sQDx0aAZ2MfXCqFEb/rG/3ghvVDIQ=="
},
"seedrandom": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
"integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
},
"select-hose": {
"version": "2.0.0",

View File

@@ -29,6 +29,7 @@
"@jitsi/logger": "2.1.1",
"@jitsi/rnnoise-wasm": "0.2.1",
"@matrix-org/olm": "3.2.15",
"@mediapipe/selfie_segmentation": "^0.1.1675465747",
"@microsoft/microsoft-graph-client": "3.0.1",
"@mui/material": "5.12.1",
"@react-native-async-storage/async-storage": "1.23.1",
@@ -44,8 +45,12 @@
"@sayem314/react-native-keep-awake": "1.3.1",
"@stomp/stompjs": "7.0.0",
"@svgr/webpack": "6.3.1",
"@tensorflow/tfjs-backend-wasm": "3.13.0",
"@tensorflow/tfjs-core": "3.13.0",
"@tensorflow-models/body-segmentation": "^1.0.2",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@tensorflow/tfjs-backend-webgpu": "^4.22.0",
"@tensorflow/tfjs-converter": "^4.22.0",
"@tensorflow/tfjs-core": "^4.22.0",
"@vladmandic/human": "2.6.5",
"@vladmandic/human-models": "2.5.9",
"@xmldom/xmldom": "0.8.7",

View File

@@ -721,6 +721,19 @@ export interface IConfig {
mobileCodecPreferenceOrder?: Array<string>;
persist?: boolean;
};
virtualBackground?: {
edgeHigh?: number;
edgeLow?: number;
enableV2?: boolean;
inferenceStride?: number;
segmentationHeight?: number;
segmentationWidth?: number;
targetFps?: number;
temporalBlendRatio?: number;
testMode?: boolean;
tierOverride?: 'high' | 'low' | 'medium';
useInsertableStreams?: boolean;
};
visitors?: {
enableMediaOnPromote?: {
audio?: boolean;

View File

@@ -242,6 +242,7 @@ export default [
'useHostPageLocalStorage',
'useTurnUdp',
'videoQuality',
'virtualBackground',
'visitors.enableMediaOnPromote',
'visitors.hideVisitorCountForVisitors',
'visitors.showJoinMeetingDialog',

View File

@@ -1,3 +1,6 @@
import { showWarningNotification } from '../../../features/notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../features/notifications/constants';
import { backgroundEnabled } from '../../../features/virtual-background/actions';
import { IStore } from '../../app/types';
import { IStateful } from '../app/types';
import { isAdvancedAudioSettingsEnabled } from '../config/functions.any';
@@ -79,7 +82,15 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
}
// Filter any undefined values returned by Promise.resolve().
const effects = effectsArray.filter(effect => Boolean(effect));
const allEffects = effectsArray.filter(effect => Boolean(effect));
// Effects that set requiresPostConstructionApplication=true (e.g. V2 insertable streams path) cannot
// be passed to the JitsiLocalTrack constructor: their startEffect() returns a
// MediaStreamTrackGenerator whose getSettings() is empty until frames flow, crashing the
// constraint-caching code in the constructor. Split them out and apply via setEffect() after the
// tracks are created.
const effects = allEffects.filter((e: any) => !e.requiresPostConstructionApplication);
const postEffects = allEffects.filter((e: any) => Boolean(e.requiresPostConstructionApplication));
return JitsiMeetJS.createLocalTracks(
{
@@ -101,6 +112,39 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
logger.error('Failed to create local tracks', options.devices, err);
return Promise.reject(err);
})
.then(async (localTracks: any[]) => {
// Apply post-construction effects (IS path) via setEffect on the video track.
for (const effect of postEffects as any[]) {
const videoTrack = localTracks.find((t: any) => effect.isEnabled(t));
if (videoTrack) {
try {
await videoTrack.setEffect(effect);
// V2 effects initialise asynchronously (worker spawn + model load).
if ((effect as any).initPromise instanceof Promise) {
await (effect as any).initPromise;
}
} catch (err) {
logger.error('Failed to apply post-construction effect', err);
try {
await videoTrack.setEffect(undefined);
} catch (cleanupErr) {
logger.warn('Failed to clear effect after init failure', cleanupErr);
}
store!.dispatch(backgroundEnabled(false));
store!.dispatch(showWarningNotification(
{ titleKey: 'virtualBackground.backgroundEffectError' },
NOTIFICATION_TIMEOUT_TYPE.LONG
));
}
}
}
return localTracks;
});
}));
}

View File

@@ -0,0 +1,313 @@
import { IConfig } from '../../base/config/configType';
import logger from '../../virtual-background/logger';
export enum BackendType {
TFLITE = 'tflite',
WEBGL = 'webgl',
WEBGPU = 'webgpu'
}
export enum DeviceTier {
HIGH = 'high',
LOW = 'low',
MEDIUM = 'medium',
UNSUPPORTED = 'unsupported'
}
export enum ModelType {
GENERAL = 'general',
LANDSCAPE = 'landscape'
}
export interface IDeviceCapabilities {
backend: BackendType;
modelType: ModelType;
overridesApplied: boolean;
reason: string;
segHeight: number;
segWidth: number;
targetFps: number;
tier: DeviceTier;
}
interface ITierProfile {
backend: BackendType;
modelType: ModelType;
segHeight: number;
segWidth: number;
targetFps: number;
}
/**
* Shape persisted to localStorage. Only the hardware-detected tier and its reason are stored;
* config overrides are never cached so they can be changed without clearing storage.
*/
interface ITierCacheEntry {
reason: string;
tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
version: number;
}
const TIER_PROFILES: Record<Exclude<DeviceTier, DeviceTier.UNSUPPORTED>, ITierProfile> = {
[DeviceTier.HIGH]: {
backend: BackendType.WEBGPU,
modelType: ModelType.LANDSCAPE,
segHeight: 288,
segWidth: 512,
targetFps: 30
},
[DeviceTier.LOW]: {
backend: BackendType.TFLITE,
modelType: ModelType.LANDSCAPE,
segHeight: 256,
segWidth: 256,
targetFps: 30
},
[DeviceTier.MEDIUM]: {
backend: BackendType.WEBGL,
modelType: ModelType.LANDSCAPE,
segHeight: 216,
segWidth: 384,
targetFps: 30
}
};
const TIER_CACHE_KEY = 'jitsi_vb_device_tier';
/**
* Bump this when the probing logic changes in a way that may yield a different result on the same hardware. Stale
* entries with an older version are discarded and a fresh probe is run.
*/
const TIER_CACHE_VERSION = 1;
/**
* Returns the cached hardware tier, or null if the cache is absent or stale.
*
* @returns {ITierCacheEntry | null}
*/
function readTierCache(): ITierCacheEntry | null {
try {
const raw = localStorage.getItem(TIER_CACHE_KEY);
if (!raw) {
return null;
}
const entry: ITierCacheEntry = JSON.parse(raw);
if (entry?.version !== TIER_CACHE_VERSION) {
return null;
}
const validTiers: string[] = [ DeviceTier.HIGH, DeviceTier.MEDIUM, DeviceTier.LOW ];
if (!validTiers.includes(entry.tier)) {
return null;
}
return entry;
} catch {
return null;
}
}
/**
* Persists the hardware-detected tier to localStorage.
*
* @param {Exclude<DeviceTier, DeviceTier.UNSUPPORTED>} tier - Detected tier.
* @param {string} reason - Human-readable detection reason.
* @returns {void}
*/
function writeTierCache(tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>, reason: string): void {
try {
const entry: ITierCacheEntry = { reason, tier, version: TIER_CACHE_VERSION };
localStorage.setItem(TIER_CACHE_KEY, JSON.stringify(entry));
} catch {
// localStorage may be unavailable (private browsing, storage quota exceeded). Non-fatal.
}
}
/**
* Runs the hardware capability probes (WebGPU → WebGL → WASM) and returns the detected tier and a human-readable
* reason string. This is the expensive part that we want to run only once.
*
* Returns null when no usable backend is found on this device (no WebGPU, WebGL, or WebAssembly).
*
* @returns {Promise<Object|null>} Resolves with a tier/reason pair, or null if no backend is available.
*/
async function probeHardwareTier(): Promise<{
reason: string;
tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
} | null> {
let tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED> | DeviceTier.UNSUPPORTED = DeviceTier.UNSUPPORTED;
let reason = '';
// --- High tier: WebGPU ---
try {
if ((navigator as any).gpu) {
const adapter = await (navigator as any).gpu.requestAdapter();
if (adapter) {
const adapterName = (adapter as any).name ?? 'unknown GPU';
logger.debug(`[VirtualBackground] WebGPU: available (adapter: "${adapterName}")`);
tier = DeviceTier.HIGH;
reason = 'WebGPU adapter found; high-end GPU path selected';
} else {
logger.debug('[VirtualBackground] WebGPU: unavailable (adapter request returned null)');
}
}
} catch {
logger.debug('[VirtualBackground] WebGPU: unavailable (requestAdapter threw)');
}
// --- Medium tier: WebGL 2 / WebGL 1 ---
// Only probed when WebGPU was not found. Each context is released immediately via WEBGL_lose_context
// so it does not count against the browser's concurrent WebGL context limit (~16 in Chrome).
if (tier === DeviceTier.UNSUPPORTED) {
const testCanvas = document.createElement('canvas');
const gl2 = testCanvas.getContext('webgl2');
if (gl2) {
gl2.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL2: available');
tier = DeviceTier.MEDIUM;
reason = 'WebGL2 available; standard path selected';
} else {
logger.debug('[VirtualBackground] WebGL2: unavailable (context creation failed)');
// webgl2 returned null — the canvas is still context-free; try webgl1.
const gl1 = testCanvas.getContext('webgl');
if (gl1) {
gl1.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL1: available');
tier = DeviceTier.MEDIUM;
reason = 'WebGL1 available; standard path selected (WebGL2 unavailable)';
} else {
logger.debug('[VirtualBackground] WebGL1: unavailable (context creation failed)');
}
}
}
// --- Low tier: WASM ---
if (tier === DeviceTier.UNSUPPORTED) {
if (typeof WebAssembly !== 'undefined') {
logger.debug('[VirtualBackground] WASM: available');
tier = DeviceTier.LOW;
reason = 'No GPU context available; WASM CPU fallback';
} else {
logger.debug('[VirtualBackground] WASM: unavailable');
return null;
}
}
return { reason, tier: tier as Exclude<DeviceTier, DeviceTier.UNSUPPORTED> };
}
/**
* Returns the device capabilities for virtual background V2 processing.
*
* Hardware detection (WebGPU → WebGL → WASM probe) runs once and the result is written to localStorage.
* Every subsequent call reads the cached tier and skips re-probing — this avoids an async WebGPU adapter request and
* redundant WebGL context creation on every effect start. Config overrides (tierOverride, segmentationWidth, etc.) are
* applied on top of the cached tier on every call and are never persisted.
*
* @param {IConfig} config - The app config object.
* @returns {Promise<IDeviceCapabilities>}
*/
export async function detectDeviceTier(config: IConfig): Promise<IDeviceCapabilities> {
let hardwareTier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
let reason: string;
const cached = readTierCache();
if (cached) {
hardwareTier = cached.tier;
reason = cached.reason;
logger.debug(`[VirtualBackground] Hardware tier (cached): ${hardwareTier}${reason}`);
} else {
logger.debug('[VirtualBackground] No cached tier — probing hardware capabilities');
const probeResult = await probeHardwareTier();
if (!probeResult) {
return {
backend: BackendType.TFLITE,
modelType: ModelType.LANDSCAPE,
overridesApplied: false,
reason: 'No WebGPU, WebGL, or WASM available — virtual background disabled',
segHeight: 0,
segWidth: 0,
targetFps: 0,
tier: DeviceTier.UNSUPPORTED
};
}
hardwareTier = probeResult.tier as Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
reason = probeResult.reason;
writeTierCache(hardwareTier, reason);
logger.info(
`[VirtualBackground] Hardware tier: ${hardwareTier}${reason} (written to cache)`);
}
// Build capabilities from the hardware tier profile.
const profile = TIER_PROFILES[hardwareTier];
const caps: IDeviceCapabilities = {
...profile,
overridesApplied: false,
reason,
tier: hardwareTier
};
logger.debug(
`[VirtualBackground] → Tier: ${caps.tier} | Backend: ${caps.backend} | `
+ `Seg: ${caps.segWidth}×${caps.segHeight} | FPS: ${caps.targetFps} | Model: ${caps.modelType}`);
// Apply config overrides on every call — never cached.
const vbConfig = config.virtualBackground;
let anyOverride = false;
if (vbConfig) {
if (vbConfig.tierOverride && vbConfig.tierOverride !== caps.tier) {
const overrideTier = vbConfig.tierOverride as Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
const overrideProfile = TIER_PROFILES[overrideTier];
if (overrideProfile) {
caps.tier = overrideTier;
caps.backend = overrideProfile.backend;
caps.segHeight = overrideProfile.segHeight;
caps.segWidth = overrideProfile.segWidth;
caps.targetFps = overrideProfile.targetFps;
caps.modelType = overrideProfile.modelType;
logger.debug(`[VirtualBackground] Config override: tier forced to ${overrideTier}`);
anyOverride = true;
}
}
if (typeof vbConfig.segmentationWidth === 'number') {
caps.segWidth = vbConfig.segmentationWidth;
logger.debug(`[VirtualBackground] Config override: segmentationWidth = ${caps.segWidth}`);
anyOverride = true;
}
if (typeof vbConfig.segmentationHeight === 'number') {
caps.segHeight = vbConfig.segmentationHeight;
logger.debug(`[VirtualBackground] Config override: segmentationHeight = ${caps.segHeight}`);
anyOverride = true;
}
if (typeof vbConfig.targetFps === 'number') {
caps.targetFps = vbConfig.targetFps;
logger.debug(`[VirtualBackground] Config override: targetFps = ${caps.targetFps}`);
anyOverride = true;
}
}
caps.overridesApplied = anyOverride;
return caps;
}

View File

@@ -12,6 +12,17 @@ export interface IBackgroundEffectOptions {
virtualBackground: {
backgroundType?: string;
blurValue?: number;
studioLightOptions?: {
bgDimming?: number;
brightness?: number;
contrast?: number;
glowIntensity?: number;
saturation?: number;
skinSmoothing?: number;
toneB?: number;
toneG?: number;
toneR?: number;
};
virtualSource?: string;
};
width: number;
@@ -235,7 +246,7 @@ export default class JitsiStreamBackgroundEffect {
this._segmentationMaskCanvas = document.createElement('canvas');
this._segmentationMaskCanvas.width = this._options.width;
this._segmentationMaskCanvas.height = this._options.height;
this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d');
this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d', { willReadFrequently: true });
this._outputCanvasElement.width = parseInt(width, 10);
this._outputCanvasElement.height = parseInt(height, 10);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
/**
* Virtual Background V2 — inference worker.
*
* Handles TF.js body-segmentation (MEDIUM/HIGH tiers via WebGL/WebGPU) and TFLite WASM
* (LOW tier via selfie_segmenter FP16) in a single dedicated Web Worker. Running
* all inference in a Worker keeps the main thread free for UI events. For GPU tiers, the
* Worker's OffscreenCanvas-backed WebGL context is not subject to Chrome's tab-visibility
* GPU scheduling throttle.
*/
/*
* Message protocol
*
* Main -> Worker:
* { type: 'init', backend: string, modelType: string, segWidth: number, segHeight: number,
* tfliteModelPath: string, tfliteWasmBase: string }
* { type: 'infer', bitmap: ImageBitmap } (bitmap is transferred -- zero-copy)
* { type: 'stop' }
*
* Worker -> Main:
* { type: 'init_done', backend: string, segHeight: number, segWidth: number }
* backend/segHeight/segWidth reflect the actual tier used (may differ from requested
* backend when the Worker fell back to TFLite because the GPU backend was unavailable)
* { type: 'init_error', error: string }
* { type: 'mask', data: Uint8ClampedArray, width: number, height: number }
* (data.buffer is transferred -- zero-copy)
* { type: 'infer_error', error: string }
*/
import '@tensorflow/tfjs-backend-webgl';
import '@tensorflow/tfjs-backend-webgpu';
import * as tf from '@tensorflow/tfjs-core';
import type { BodySegmenter, MediaPipeSelfieSegmentationTfjsModelConfig }
from '@tensorflow-models/body-segmentation';
import * as bs from '@tensorflow-models/body-segmentation';
import { BackendType } from './DeviceTierDetector.web';
/* eslint-disable lines-around-comment */
// @ts-ignore
import createTFLiteModule from './vendor/tflite/tflite';
// @ts-ignore
import createTFLiteSIMDModule from './vendor/tflite/tflite-simd';
/* eslint-enable lines-around-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Maximum segmentation dimension in pixels. Guards against OOM via crafted init messages. */
const MAX_SEG_DIMENSION = 2048;
/**
* Extracts the effective origin of this Worker script.
*
* Workers loaded via a blob URL have self.location.href of the form
* "blob:https://hostname/uuid". Standard URL parsing returns origin="null" for blob URLs,
* so we strip the "blob:" prefix and parse the embedded URL to recover the real origin.
*
* @returns {string} The origin (e.g. "https://meet.example.com"), or empty string if unparseable.
*/
function getWorkerOrigin(): string {
try {
const { href } = self.location;
return new URL(href.startsWith('blob:') ? href.slice(5) : href).origin;
} catch {
return '';
}
}
/**
* Returns true when {@code url} is a valid http/https URL at the same origin as this Worker.
* Rejects data URIs, javascript: URLs, file: URLs, and cross-origin paths.
*
* @param {string} url - URL to validate.
* @returns {boolean}
*/
function isSameOriginUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (parsed.protocol === 'https:' || parsed.protocol === 'http:')
&& parsed.origin === getWorkerOrigin();
} catch {
return false;
}
}
/** Active TF.js segmenter (MEDIUM/HIGH tiers). Null when using TFLite backend. */
let segmenter: BodySegmenter | null = null;
/** Active TFLite module (LOW tier, TFLITE backend). Null when using TF.js. */
let tfliteModule: any = null;
/** TFLite input HEAPF32 offset (byte offset / 4). Pre-computed after model load. */
let tfliteInputOffset = 0;
/** TFLite output HEAPF32 offset (byte offset / 4). Pre-computed after model load. */
let tfliteOutputOffset = 0;
/** TFLite segmentation pixel count (segWidth x segHeight). */
let tflitePixelCount = 0;
/** TFLite segmentation canvas width. */
let tfliteSegWidth = 0;
/** TFLite segmentation canvas height. */
let tfliteSegHeight = 0;
/** OffscreenCanvas for downscaling frames before TFLite inference. */
let tfliteReadCanvas: any = null;
/** 2D context for tfliteReadCanvas. */
let tfliteReadCtx: any = null;
/** Active backend type. TFLITE selects TFLite WASM; WEBGL/WEBGPU selects TF.js. */
let currentBackend: BackendType | '' = '';
/**
* Type-safe postMessage wrapper for DedicatedWorkerGlobalScope.
* TypeScript types the global 'self' as Window (lib.dom) which has a different postMessage
* signature to DedicatedWorkerGlobalScope. Casting through 'any' bypasses the mismatch.
*
* @param {any} msg - Structured-cloneable message payload.
* @param {Transferable[]} [transfer] - Optional list of transferable objects (zero-copy).
* @returns {void}
*/
function workerPost(msg: any, transfer?: Transferable[]): void {
if (transfer?.length) {
(self as any).postMessage(msg, transfer);
} else {
(self as any).postMessage(msg);
}
}
// onmessage at module scope — runs in DedicatedWorkerGlobalScope after importScripts loads this bundle.
// TypeScript types 'onmessage' as Window.onmessage, but the runtime target is the worker global.
(self as any).onmessage = async (e: MessageEvent) => {
const { type } = e.data;
if (type === 'init') {
await handleInit(e.data);
} else if (type === 'infer') {
await handleInfer(e.data.bitmap as ImageBitmap);
} else if (type === 'stop') {
handleStop();
}
};
/**
* Initialises the worker backend based on the tier signalled by the main thread.
*
* Routes to the TFLite handler for LOW tier (backend 'tflite') or the TF.js handler for
* MEDIUM/HIGH tiers (backend 'webgl' or 'webgpu'). The TFLite model path and WASM base are
* always passed so the TF.js handler can fall back to TFLite if GPU init fails in the Worker.
*
* @param {Object} data - Init message payload.
* @param {string} data.backend - 'tflite', 'webgl', or 'webgpu'.
* @param {string} data.modelType - 'general' or 'landscape' (TF.js tiers only).
* @param {number} data.segHeight - Segmentation canvas height.
* @param {number} data.segWidth - Segmentation canvas width.
* @param {string} data.tfliteModelPath - Absolute URL of the TFLite segmentation model.
* @param {string} data.tfliteWasmBase - Base URL for tflite*.wasm binaries (trailing slash).
* @returns {Promise<void>}
*/
async function handleInit(data: {
backend: BackendType;
modelType: string;
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
// Whitelist backend — rejects unknown values before they reach tf.setBackend() or TFLite.
if (data.backend !== BackendType.TFLITE
&& data.backend !== BackendType.WEBGL
&& data.backend !== BackendType.WEBGPU) {
workerPost({ error: `Unknown backend: "${data.backend}"`, type: 'init_error' });
return;
}
// Bounds-check dimensions — prevents OOM from crafted init messages.
if (!Number.isInteger(data.segWidth) || data.segWidth < 1 || data.segWidth > MAX_SEG_DIMENSION
|| !Number.isInteger(data.segHeight) || data.segHeight < 1
|| data.segHeight > MAX_SEG_DIMENSION) {
workerPost({ error: 'Invalid segmentation dimensions in init message', type: 'init_error' });
return;
}
currentBackend = data.backend;
if (data.backend === BackendType.TFLITE) {
await handleInitTflite(data);
} else {
await handleInitTfjs(data);
}
}
/**
* Initialises TF.js with the tier-appropriate backend and creates the body-segmentation model.
* In a Worker, TF.js automatically uses OffscreenCanvas for its WebGL context (document is
* unavailable). Chrome does not apply tab-visibility GPU throttling to Worker GPU contexts.
*
* If the requested GPU backend is unavailable (no GPU, OffscreenCanvas WebGL unsupported, etc.),
* falls back to TFLite WASM using the TFLite model path supplied in the init message. This covers
* servers and VMs where the main-thread tier detector passes GPU checks (software rasteriser) but
* the Worker's OffscreenCanvas context creation fails.
*
* @param {Object} data - Init message payload.
* @param {string} data.backend - 'webgl' (MEDIUM tier) or 'webgpu' (HIGH tier).
* @param {string} data.modelType - 'general' or 'landscape'.
* @param {number} data.segWidth - Unused here; inference at bitmap resolution.
* @param {number} data.segHeight - Unused here; inference at bitmap resolution.
* @param {string} data.tfliteModelPath - TFLite model URL used if GPU fallback is triggered.
* @param {string} data.tfliteWasmBase - TFLite WASM base URL used if GPU fallback is triggered.
* @returns {Promise<void>}
*/
async function handleInitTfjs(data: {
backend: string;
modelType: string;
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
const { backend, modelType, tfliteModelPath, tfliteWasmBase } = data;
try {
const backendSet = await tf.setBackend(backend);
await tf.ready();
if (!backendSet || tf.getBackend() !== backend) {
// GPU backend unavailable in this Worker context — fall back to TFLite.
currentBackend = BackendType.TFLITE;
await handleInitTflite({ segHeight: 256, segWidth: 256, tfliteModelPath, tfliteWasmBase });
return;
}
segmenter = await bs.createSegmenter(
bs.SupportedModels.MediaPipeSelfieSegmentation,
{ modelType, runtime: 'tfjs' } as MediaPipeSelfieSegmentationTfjsModelConfig
);
workerPost({ backend: currentBackend, segHeight: data.segHeight, segWidth: data.segWidth, type: 'init_done' });
} catch (err) {
// GPU init threw (e.g. OffscreenCanvas WebGL context creation failed) — fall back to TFLite.
currentBackend = BackendType.TFLITE;
try {
await handleInitTflite({ segHeight: 256, segWidth: 256, tfliteModelPath, tfliteWasmBase });
} catch (tfliteErr) {
workerPost({ error: String(tfliteErr), type: 'init_error' });
}
}
}
/**
* Returns true when the runtime supports WebAssembly SIMD instructions.
*
* Uses WebAssembly.validate with a minimal module containing a v128.const SIMD instruction.
* Returns false on any error (older browsers, WASM disabled, etc.).
*
* @returns {boolean}
*/
function detectSimd(): boolean {
try {
return WebAssembly.validate(new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123,
3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11
]));
} catch {
return false;
}
}
/**
* Initialises the TFLite WASM backend for LOW tier using the selfie_segmenter model.
*
* Loads the appropriate WASM runtime (SIMD or standard) using the createTFLiteModule /
* createTFLiteSIMDModule factory with a locateFile override so the .wasm binary is resolved
* from the deployment libs/ directory rather than from the (unusable) blob: worker origin.
* Fetches the .tflite model, writes it to the TFLite HEAPU8 buffer, and calls _loadModel().
* Pre-computes HEAPF32 input/output offsets and creates an OffscreenCanvas for pixel readback.
*
* @param {Object} data - Init message payload for the TFLITE tier.
* @param {string} data.modelPath - Absolute URL of the TFLite segmentation model.
* @param {number} data.segHeight - Segmentation canvas height.
* @param {number} data.segWidth - Segmentation canvas width.
* @param {string} data.tfliteWasmBase - Base URL for tflite*.wasm binaries (trailing slash).
* @returns {Promise<void>}
*/
async function handleInitTflite(data: {
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
try {
const { segHeight, segWidth, tfliteModelPath, tfliteWasmBase } = data;
if (!isSameOriginUrl(tfliteModelPath) || !isSameOriginUrl(`${tfliteWasmBase}tflite.wasm`)) {
workerPost({ error: 'TFLite model or WASM path must be same-origin', type: 'init_error' });
return;
}
// Select SIMD-optimised runtime when available — roughly 2x faster than standard WASM.
const simdSupported = detectSimd();
const tfliteFactory = simdSupported ? createTFLiteSIMDModule : createTFLiteModule;
// locateFile overrides WASM path resolution — required because the worker runs from a
// blob: URL whose origin is "null", making relative path resolution fail.
tfliteModule = await tfliteFactory({
locateFile: (path: string) => `${tfliteWasmBase}${path}`
});
// Fetch and load model into TFLite WASM heap.
const modelResponse = await fetch(tfliteModelPath, { credentials: 'same-origin' });
if (!modelResponse.ok) {
workerPost({ error: `TFLite model fetch failed: HTTP ${modelResponse.status}`, type: 'init_error' });
return;
}
const modelBuffer = await modelResponse.arrayBuffer();
tfliteModule.HEAPU8.set(new Uint8Array(modelBuffer), tfliteModule._getModelBufferMemoryOffset());
tfliteModule._loadModel(modelBuffer.byteLength);
// Pre-compute HEAPF32 offsets to avoid divide-by-4 on every frame.
tfliteInputOffset = tfliteModule._getInputMemoryOffset() / 4;
tfliteOutputOffset = tfliteModule._getOutputMemoryOffset() / 4;
tfliteSegWidth = segWidth;
tfliteSegHeight = segHeight;
tflitePixelCount = segWidth * segHeight;
tfliteReadCanvas = new OffscreenCanvas(segWidth, segHeight);
tfliteReadCtx = tfliteReadCanvas.getContext('2d', { willReadFrequently: true });
workerPost({ backend: currentBackend, segHeight: tfliteSegHeight, segWidth: tfliteSegWidth, type: 'init_done' });
} catch (err) {
workerPost({ error: String(err), type: 'init_error' });
}
}
/**
* Dispatches an inference call to the TFLite or TF.js handler based on the active backend.
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at the tier's segmentation resolution.
* @returns {Promise<void>}
*/
async function handleInfer(bitmap: ImageBitmap): Promise<void> {
if (currentBackend === BackendType.TFLITE) {
await handleInferTflite(bitmap);
} else {
await handleInferTfjs(bitmap);
}
}
/**
* Runs one TF.js inference cycle on the transferred ImageBitmap.
*
* The bitmap is at the tier's segmentation resolution (pre-scaled on the main thread via
* createImageBitmap resize options). TF.js skips its own internal resize, reducing per-frame cost.
* The raw mask Uint8ClampedArray is transferred back (zero-copy) for EMA smoothing and WebGL
* compositing on the main thread.
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at seg resolution.
* @returns {Promise<void>}
*/
async function handleInferTfjs(bitmap: ImageBitmap): Promise<void> {
if (!segmenter) {
bitmap.close();
workerPost({ error: 'Segmenter not initialised', type: 'infer_error' });
return;
}
tf.engine().startScope();
try {
const segmentations = await segmenter.segmentPeople(
bitmap as unknown as HTMLImageElement, { flipHorizontal: false }
);
if (!segmentations.length) {
workerPost({ error: 'No segmentation result', type: 'infer_error' });
return;
}
const maskData = await segmentations[0].mask.toImageData();
const data = maskData.data;
// Transfer the backing ArrayBuffer — zero-copy, data becomes detached in this worker.
workerPost(
{ data, height: maskData.height, type: 'mask', width: maskData.width },
[ data.buffer as ArrayBuffer ]
);
} catch (err) {
workerPost({ error: String(err), type: 'infer_error' });
} finally {
// Close unconditionally — called in finally so the bitmap is always released
// even when segmentPeople() throws before the try-block close is reached.
bitmap.close();
tf.engine().endScope();
}
}
/**
* Runs one TFLite inference cycle on the transferred ImageBitmap.
*
* Draws the bitmap to an OffscreenCanvas, fills the TFLite HEAPF32 input buffer with NHWC
* float32 RGB values normalised to [0,1], calls _runInference(), and reads the single-channel
* output (selfie_segmenter outputs one float per pixel: person confidence in [0,1]).
* Writes the confidence value to both R and A channels of the returned Uint8ClampedArray so
* it is compatible with both the WebGL compositor (reads .r) and the Canvas 2D fallback (reads alpha).
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at seg resolution.
* @returns {Promise<void>}
*/
async function handleInferTflite(bitmap: ImageBitmap): Promise<void> {
if (!tfliteModule || !tfliteReadCtx) {
bitmap.close();
workerPost({ error: 'TFLite not initialised', type: 'infer_error' });
return;
}
try {
// Draw pre-scaled bitmap to the read canvas and extract RGBA pixels.
tfliteReadCtx.drawImage(bitmap, 0, 0, tfliteSegWidth, tfliteSegHeight);
const imageData = tfliteReadCtx.getImageData(0, 0, tfliteSegWidth, tfliteSegHeight);
// Fill NHWC float32 input [1, H, W, 3] with RGB channels normalised to [0, 1].
const pixelCount = tflitePixelCount;
for (let i = 0; i < pixelCount; i++) {
tfliteModule.HEAPF32[tfliteInputOffset + i * 3] = imageData.data[i * 4] / 255;
tfliteModule.HEAPF32[tfliteInputOffset + i * 3 + 1] = imageData.data[i * 4 + 1] / 255;
tfliteModule.HEAPF32[tfliteInputOffset + i * 3 + 2] = imageData.data[i * 4 + 2] / 255;
}
// Run TFLite inference (synchronous WASM call).
tfliteModule._runInference();
// Read single-channel output: one float per pixel (person confidence in [0, 1]).
// selfie_segmenter outputs probabilities directly — no softmax needed.
const maskBytes = new Uint8ClampedArray(pixelCount * 4);
for (let i = 0; i < pixelCount; i++) {
const v = Math.round(tfliteModule.HEAPF32[tfliteOutputOffset + i] * 255);
maskBytes[i * 4] = v; // R — read by WebGL compositor shader (.r channel)
maskBytes[i * 4 + 3] = v; // A — read by Canvas 2D fallback compositing path
}
workerPost(
{ data: maskBytes, height: tfliteSegHeight, type: 'mask', width: tfliteSegWidth },
[ maskBytes.buffer ]
);
} catch (err) {
workerPost({ error: String(err), type: 'infer_error' });
} finally {
bitmap.close();
}
}
/**
* Disposes all backend resources and resets state.
*
* @returns {void}
*/
function handleStop(): void {
segmenter?.dispose();
segmenter = null;
// TFLite: module has no explicit dispose API; null refs allow GC.
tfliteModule = null;
tfliteReadCanvas = null;
tfliteReadCtx = null;
currentBackend = '';
}

View File

@@ -0,0 +1,726 @@
import logger from '../../virtual-background/logger';
const VERTEX_SHADER_SOURCE = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// GLSL ES 1.0 — compatible with both WebGL 1 and WebGL 2.
const VB_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_camera;
uniform sampler2D u_background;
uniform sampler2D u_mask;
uniform sampler2D u_prevMask;
uniform float u_temporalRatio;
uniform float u_edgeLow;
uniform float u_edgeHigh;
uniform vec2 u_maskTexelSize;
uniform vec2 u_cameraTexelSize;
varying vec2 v_texCoord;
// 3x3 Gaussian blur (sigma≈1) sampled in mask-texture space.
// When u_maskTexelSize is (0,0) all taps collapse to tc — weights sum to 1.0, no blur.
float sampleMaskBlurred(sampler2D tex, vec2 tc) {
vec2 t = u_maskTexelSize;
return
texture2D(tex, tc + vec2(-t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, -t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2(-t.x, 0.0)).r * 0.125 +
texture2D(tex, tc ).r * 0.25 +
texture2D(tex, tc + vec2( t.x, 0.0)).r * 0.125 +
texture2D(tex, tc + vec2(-t.x, t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, t.y)).r * 0.0625;
}
// Luminance gradient magnitude at this pixel using a simple cross-pattern sample.
// Returns values in roughly [0, 0.5] — strong camera edges (e.g. shirt vs chair) hit 0.2+.
float cameraEdgeStrength(vec2 tc) {
vec2 t = u_cameraTexelSize;
vec3 lv = vec3(0.299, 0.587, 0.114);
float lL = dot(texture2D(u_camera, tc - vec2(t.x, 0.0)).rgb, lv);
float lR = dot(texture2D(u_camera, tc + vec2(t.x, 0.0)).rgb, lv);
float lU = dot(texture2D(u_camera, tc - vec2(0.0, t.y)).rgb, lv);
float lD = dot(texture2D(u_camera, tc + vec2(0.0, t.y)).rgb, lv);
return length(vec2(lR - lL, lD - lU));
}
void main() {
vec4 fg = texture2D(u_camera, v_texCoord);
vec4 bg = texture2D(u_background, v_texCoord);
// Blurred mask — wide feathering for smooth regions (hair, soft edges).
float curBlurred = sampleMaskBlurred(u_mask, v_texCoord);
float prevBlurred = sampleMaskBlurred(u_prevMask, v_texCoord);
// Raw (unblurred) mask — used to snap the edge at sharp camera boundaries.
float curRaw = texture2D(u_mask, v_texCoord).r;
float prevRaw = texture2D(u_prevMask, v_texCoord).r;
// At strong camera color edges (e.g. dark chair vs bright shirt) snap toward the raw mask
// so the person boundary aligns with the actual image edge. At smooth regions (hair, skin)
// keep the blurred mask for natural feathering.
float snapT = smoothstep(0.05, 0.20, cameraEdgeStrength(v_texCoord));
float curAlpha = mix(curBlurred, curRaw, snapT);
float prevAlpha = mix(prevBlurred, prevRaw, snapT);
float blended = mix(curAlpha, prevAlpha, u_temporalRatio);
float alpha = smoothstep(u_edgeLow, u_edgeHigh, blended);
gl_FragColor = mix(bg, fg, alpha);
}
`;
// Studio light fragment shader — applies brightness, contrast, skin smoothing,
// tone correction and edge glow to the person region identified by the mask.
const STUDIO_LIGHT_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_camera;
uniform sampler2D u_mask;
uniform sampler2D u_prevMask;
uniform float u_temporalRatio;
uniform float u_edgeLow;
uniform float u_edgeHigh;
uniform vec2 u_maskTexelSize;
uniform float u_brightness;
uniform float u_contrast;
uniform float u_glowIntensity;
uniform float u_saturation;
uniform float u_skinSmoothing;
uniform vec3 u_toneRGB;
uniform vec2 u_cameraTexelSize;
uniform float u_bgDimming;
varying vec2 v_texCoord;
float sampleMask(sampler2D tex, vec2 tc) {
vec2 t = u_maskTexelSize;
// When maskTexelSize is (0,0) all taps collapse — no blur, crisp edges.
return
texture2D(tex, tc + vec2(-t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, -t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2(-t.x, 0.0)).r * 0.125 +
texture2D(tex, tc ).r * 0.25 +
texture2D(tex, tc + vec2( t.x, 0.0)).r * 0.125 +
texture2D(tex, tc + vec2(-t.x, t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, t.y)).r * 0.0625;
}
vec4 sampleCameraBlurred(vec2 tc) {
vec2 t = u_cameraTexelSize * 2.0;
vec4 s = vec4(0.0);
s += texture2D(u_camera, tc + vec2(-2.0*t.x, -2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, -2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 0.0, -2.0*t.y)) * 0.022;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, -2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, -2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, -1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, -1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 0.0, -1.0*t.y)) * 0.098;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, -1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, -1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 0.0 )) * 0.022;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 0.0 )) * 0.098;
s += texture2D(u_camera, tc ) * 0.162;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 0.0 )) * 0.098;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 0.0 )) * 0.022;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 0.0, 1.0*t.y)) * 0.098;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 0.0, 2.0*t.y)) * 0.022;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 2.0*t.y)) * 0.003;
return s;
}
void main() {
vec4 original = texture2D(u_camera, v_texCoord);
float curAlpha = sampleMask(u_mask, v_texCoord);
float prevAlpha = sampleMask(u_prevMask, v_texCoord);
float blended = mix(curAlpha, prevAlpha, u_temporalRatio);
float alpha = smoothstep(u_edgeLow, u_edgeHigh, blended);
// 1. Skin smoothing: blend original with blurred version within mask.
vec4 blurred = sampleCameraBlurred(v_texCoord);
vec4 smoothed = mix(original, blurred, u_skinSmoothing * alpha);
// 2. Brightness and contrast.
vec3 adjusted = (smoothed.rgb - 0.5) * u_contrast + 0.5 + u_brightness;
adjusted = clamp(adjusted, 0.0, 1.0);
// 3. Tone correction (RGB gains).
adjusted *= u_toneRGB;
adjusted = clamp(adjusted, 0.0, 1.0);
// 4. Saturation: shift toward/away from luminance.
float luma = dot(adjusted, vec3(0.2126, 0.7152, 0.0722));
adjusted = mix(vec3(luma), adjusted, u_saturation);
adjusted = clamp(adjusted, 0.0, 1.0);
// 5. Edge glow: peaks at mask boundary where alpha*(1-alpha) is max.
float edgeMask = alpha * (1.0 - alpha) * 4.0;
adjusted += adjusted * u_glowIntensity * edgeMask;
adjusted = clamp(adjusted, 0.0, 1.0);
// Dim background proportional to how far outside the person mask.
vec3 bg = original.rgb * (1.0 - u_bgDimming * (1.0 - alpha));
// Mix: person region gets studio-lit version, background dimmed.
gl_FragColor = vec4(mix(bg, adjusted, alpha), 1.0);
}
`;
// Full-screen quad: two triangles covering clip space [-1,1].
const QUAD_VERTICES = new Float32Array([
-1, -1, 0, 1,
1, -1, 1, 1,
-1, 1, 0, 0,
1, 1, 1, 0
]);
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) {
throw new Error('[VirtualBackground] WebGLCompositor: createShader returned null');
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`[VirtualBackground] WebGLCompositor: shader compile error — ${info}`);
}
return shader;
}
function createProgram(gl: WebGLRenderingContext, fragmentSource: string): WebGLProgram {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
if (!program) {
throw new Error('[VirtualBackground] WebGLCompositor: createProgram returned null');
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`[VirtualBackground] WebGLCompositor: program link error — ${info}`);
}
gl.deleteShader(vert);
gl.deleteShader(frag);
return program;
}
function createTexture(gl: WebGLRenderingContext): WebGLTexture {
const tex = gl.createTexture();
if (!tex) {
throw new Error('[VirtualBackground] WebGLCompositor: createTexture returned null');
}
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return tex;
}
/**
* WebGL-based compositor that blends camera, background, and segmentation mask
* using a fragment shader with temporal smoothing and edge feathering (smoothstep).
*
* Falls back gracefully when context creation fails — callers should check
* {@link WebGLCompositor#isAvailable} before use.
*/
export default class WebGLCompositor {
_canvas: HTMLCanvasElement;
_gl: WebGLRenderingContext | null = null;
// Bound event handlers — stored so they can be removed in dispose().
_onContextLostBound: ((event: Event) => void) | null = null;
_onContextRestoredBound: (() => void) | null = null;
_program: WebGLProgram | null = null;
_quadBuffer: WebGLBuffer | null = null;
_texBackground: WebGLTexture | null = null;
_texCamera: WebGLTexture | null = null;
_texMask: WebGLTexture | null = null;
_texMaskB: WebGLTexture | null = null;
// Attribute locations — resolved once after program link, reused every frame.
// When sharing a GL context with TF.js the vertex attribute state (buffer
// binding + vertexAttribPointer) is global and gets overwritten by TF.js
// inference. Re-binding before drawArrays restores our state without the
// overhead of re-querying locations each frame.
_aPosition = -1;
_aTexCoord = -1;
// Uniform locations — VB program, resolved once after program link.
_uBackground: WebGLUniformLocation | null = null;
_uCamera: WebGLUniformLocation | null = null;
_uCameraTexelSize: WebGLUniformLocation | null = null;
_uEdgeHigh: WebGLUniformLocation | null = null;
_uEdgeLow: WebGLUniformLocation | null = null;
_uMask: WebGLUniformLocation | null = null;
_uMaskTexelSize: WebGLUniformLocation | null = null;
_uPrevMask: WebGLUniformLocation | null = null;
_uTemporalRatio: WebGLUniformLocation | null = null;
// Studio light program and uniform locations.
_studioProgram: WebGLProgram | null = null;
_uStBgDimming: WebGLUniformLocation | null = null;
_uStBrightness: WebGLUniformLocation | null = null;
_uStCamera: WebGLUniformLocation | null = null;
_uStCameraTexelSize: WebGLUniformLocation | null = null;
_uStContrast: WebGLUniformLocation | null = null;
_uStEdgeHigh: WebGLUniformLocation | null = null;
_uStEdgeLow: WebGLUniformLocation | null = null;
_uStGlowIntensity: WebGLUniformLocation | null = null;
_uStMask: WebGLUniformLocation | null = null;
_uStMaskTexelSize: WebGLUniformLocation | null = null;
_uStPrevMask: WebGLUniformLocation | null = null;
_uStSaturation: WebGLUniformLocation | null = null;
_uStSkinSmoothing: WebGLUniformLocation | null = null;
_uStTemporalRatio: WebGLUniformLocation | null = null;
_uStToneRGB: WebGLUniformLocation | null = null;
/**
* Creates a WebGLCompositor targeting the given canvas.
*
* @param {HTMLCanvasElement} canvas - Output canvas element.
*/
constructor(canvas: HTMLCanvasElement) {
this._canvas = canvas;
this._init();
this._onContextLostBound = this._onContextLost.bind(this);
this._onContextRestoredBound = this._onContextRestored.bind(this);
canvas.addEventListener('webglcontextlost', this._onContextLostBound, false);
canvas.addEventListener('webglcontextrestored', this._onContextRestoredBound, false);
}
/**
* Returns true if a WebGL context is active and ready.
*
* @returns {boolean}
*/
get isAvailable(): boolean {
return this._gl !== null && !this._gl.isContextLost();
}
/**
* Composites using raw {@code ImageData} for the segmentation mask.
*
* Identical to {@link compositeWithSource} in every way except the mask is
* uploaded via the {@code Uint8Array} form of {@code texImage2D} rather than
* via the {@code CanvasImageSource} form. This is critical when the mask data
* comes from {@code Mask.toImageData()} because the {@code CanvasImageSource}
* upload path reads from the browser's internal canvas storage which uses
* <em>premultiplied alpha</em>: for this model the R and A channels are both
* equal to the segmentation confidence, so the canvas stores
* {@code R = confidence²} instead of {@code confidence}. The raw upload path
* bypasses this distortion entirely.
*
* Temporal ping-pong is still managed GPU-side via {@code _texMask} /
* {@code _texMaskB} — no CPU-side mask copy is needed.
*
* @param {TexImageSource} cameraSource - Live camera frame (HTMLVideoElement, ImageBitmap, VideoFrame, etc.).
* @param {HTMLCanvasElement} backgroundSource - Pre-rendered background canvas.
* @param {ImageData} maskData - Current-frame segmentation mask (raw bytes).
* @param {number} temporalRatio - Blend weight toward previous mask.
* @param {number} edgeLow - Smoothstep lower threshold.
* @param {number} edgeHigh - Smoothstep upper threshold.
* @param {boolean} isFirstFrame - When true, seeds the previous-mask texture
* from the current mask so the first frame blends against itself.
* @param {number} maskBlurRadius - Gaussian blur radius in mask-texture pixels (0 = no blur).
* Pass 4 for TF.js body-segmentation (near-binary output needs softening); 0 for ORT/TFLite.
* @returns {void}
*/
compositeFromImageData(
cameraSource: TexImageSource,
backgroundSource: HTMLCanvasElement,
maskData: ImageData,
temporalRatio: number,
edgeLow: number,
edgeHigh: number,
isFirstFrame: boolean,
maskBlurRadius = 0
): void {
const gl = this._gl;
if (!gl || !this._program) {
return;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this._canvas.width, this._canvas.height);
gl.useProgram(this._program);
// Camera texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texCamera);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, cameraSource);
gl.uniform1i(this._uCamera, 0);
// Background texture
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._texBackground);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, backgroundSource);
gl.uniform1i(this._uBackground, 1);
// Current mask — raw Uint8Array upload bypasses browser premultiplied-alpha
// conversion that would corrupt the R channel when uploading via canvas.
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this._texMask);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
gl.uniform1i(this._uMask, 2);
// Previous mask — GPU ping-pong via _texMaskB.
// On the first frame, seed from the current mask so the blend starts
// with a consistent state.
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this._texMaskB);
if (isFirstFrame) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
}
gl.uniform1i(this._uPrevMask, 3);
gl.uniform1f(this._uTemporalRatio, temporalRatio);
gl.uniform1f(this._uEdgeLow, edgeLow);
gl.uniform1f(this._uEdgeHigh, edgeHigh);
gl.uniform2f(this._uMaskTexelSize, maskBlurRadius / maskData.width, maskBlurRadius / maskData.height);
gl.uniform2f(this._uCameraTexelSize, 1.0 / this._canvas.width, 1.0 / this._canvas.height);
this._bindQuadState(gl);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// GPU ping-pong: swap _texMask ↔ _texMaskB.
const tmp = this._texMask;
this._texMask = this._texMaskB;
this._texMaskB = tmp;
}
/**
* Composites the camera frame with studio light effects applied to the person region.
*
* @param {TexImageSource} cameraSource - Live camera frame.
* @param {ImageData} maskData - Current-frame segmentation mask.
* @param {number} temporalRatio - Blend weight toward previous mask.
* @param {number} edgeLow - Smoothstep lower threshold.
* @param {number} edgeHigh - Smoothstep upper threshold.
* @param {boolean} isFirstFrame - Seeds previous-mask from current on first frame.
* @param {number} maskBlurRadius - Gaussian blur radius in mask-texture pixels.
* @param {number} brightness - Brightness offset.
* @param {number} contrast - Contrast multiplier.
* @param {number} skinSmoothing - Skin smoothing blend factor (0-1).
* @param {number} glowIntensity - Edge glow intensity.
* @param {number} toneR - Red channel gain.
* @param {number} toneG - Green channel gain.
* @param {number} toneB - Blue channel gain.
* @param {number} saturation - Color saturation (1.0 = normal, greater is more vivid, less is muted).
* @param {number} bgDimming - Background dimming factor (0.0 = none, 1.0 = fully black).
* @returns {void}
*/
compositeStudioLight(
cameraSource: TexImageSource,
maskData: ImageData,
temporalRatio: number,
edgeLow: number,
edgeHigh: number,
isFirstFrame: boolean,
maskBlurRadius: number,
brightness: number,
contrast: number,
skinSmoothing: number,
glowIntensity: number,
toneR: number,
toneG: number,
toneB: number,
saturation: number,
bgDimming = 0
): void {
const gl = this._gl;
if (!gl || !this._studioProgram) {
return;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this._canvas.width, this._canvas.height);
gl.useProgram(this._studioProgram);
// Camera texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texCamera);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, cameraSource);
gl.uniform1i(this._uStCamera, 0);
// Current mask — raw Uint8Array upload bypasses premultiplied-alpha.
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._texMask);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
gl.uniform1i(this._uStMask, 1);
// Previous mask — GPU ping-pong via _texMaskB.
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this._texMaskB);
if (isFirstFrame) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
}
gl.uniform1i(this._uStPrevMask, 2);
// Mask uniforms
gl.uniform1f(this._uStTemporalRatio, temporalRatio);
gl.uniform1f(this._uStEdgeLow, edgeLow);
gl.uniform1f(this._uStEdgeHigh, edgeHigh);
gl.uniform2f(this._uStMaskTexelSize,
maskBlurRadius / maskData.width, maskBlurRadius / maskData.height);
// Studio light uniforms
gl.uniform1f(this._uStBrightness, brightness);
gl.uniform1f(this._uStContrast, contrast);
gl.uniform1f(this._uStSaturation, saturation);
gl.uniform1f(this._uStSkinSmoothing, skinSmoothing);
gl.uniform1f(this._uStGlowIntensity, glowIntensity);
gl.uniform3f(this._uStToneRGB, toneR, toneG, toneB);
gl.uniform1f(this._uStBgDimming, bgDimming);
gl.uniform2f(this._uStCameraTexelSize,
1.0 / this._canvas.width, 1.0 / this._canvas.height);
this._bindQuadState(gl);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// GPU ping-pong: swap _texMask and _texMaskB.
const tmp = this._texMask;
this._texMask = this._texMaskB;
this._texMaskB = tmp;
}
/**
* Returns true if the studio light shader program compiled successfully.
*
* @returns {boolean}
*/
get isStudioLightAvailable(): boolean {
return this._studioProgram !== null && this.isAvailable;
}
/**
* Releases all WebGL resources.
*
* @returns {void}
*/
dispose(): void {
// Remove event listeners first so the canvas no longer holds a reference
// to this compositor via the bound handler closures.
if (this._onContextLostBound) {
this._canvas.removeEventListener('webglcontextlost', this._onContextLostBound);
this._onContextLostBound = null;
}
if (this._onContextRestoredBound) {
this._canvas.removeEventListener('webglcontextrestored', this._onContextRestoredBound);
this._onContextRestoredBound = null;
}
const gl = this._gl;
if (!gl) {
return;
}
gl.deleteTexture(this._texCamera);
gl.deleteTexture(this._texBackground);
gl.deleteTexture(this._texMask);
gl.deleteTexture(this._texMaskB);
gl.deleteBuffer(this._quadBuffer);
gl.deleteProgram(this._program);
gl.deleteProgram(this._studioProgram);
this._texCamera = null;
this._texBackground = null;
this._texMask = null;
this._texMaskB = null;
this._quadBuffer = null;
this._program = null;
this._studioProgram = null;
// Explicitly release the WebGL context so it no longer counts against the browser's
// ~16 concurrent context limit. Without this the context stays alive until GC.
gl.getExtension('WEBGL_lose_context')?.loseContext();
this._gl = null;
}
/**
* Initialises (or re-initialises) the WebGL context, shaders, and textures.
*
* @private
* @returns {void}
*/
_init(): void {
const gl = (this._canvas.getContext('webgl2')
?? this._canvas.getContext('webgl')) as WebGLRenderingContext | null;
if (!gl) {
logger.warn('[VirtualBackground] WebGLCompositor: no WebGL context available — falling back to Canvas 2D');
return;
}
this._gl = gl;
try {
this._program = createProgram(gl, VB_FRAGMENT_SHADER_SOURCE);
} catch (err) {
logger.error('[VirtualBackground] WebGLCompositor: VB shader build failed', err);
this._gl = null;
return;
}
try {
this._studioProgram = createProgram(gl, STUDIO_LIGHT_FRAGMENT_SHADER_SOURCE);
} catch (err) {
logger.warn('[VirtualBackground] WebGLCompositor: studio light shader build failed', err);
// Non-fatal — VB still works. Studio light compositing will fall back to Canvas 2D.
}
// Vertex buffer — shared position + texCoord interleaved
this._quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW);
this._aPosition = gl.getAttribLocation(this._program, 'a_position');
this._aTexCoord = gl.getAttribLocation(this._program, 'a_texCoord');
// Initial attribute setup — _bindQuadState() repeats this before every
// draw call so TF.js cannot permanently corrupt our attribute state.
this._bindQuadState(gl);
// Resolve VB uniform locations.
gl.useProgram(this._program);
this._uCamera = gl.getUniformLocation(this._program, 'u_camera');
this._uBackground = gl.getUniformLocation(this._program, 'u_background');
this._uCameraTexelSize = gl.getUniformLocation(this._program, 'u_cameraTexelSize');
this._uMask = gl.getUniformLocation(this._program, 'u_mask');
this._uMaskTexelSize = gl.getUniformLocation(this._program, 'u_maskTexelSize');
this._uPrevMask = gl.getUniformLocation(this._program, 'u_prevMask');
this._uTemporalRatio = gl.getUniformLocation(this._program, 'u_temporalRatio');
this._uEdgeLow = gl.getUniformLocation(this._program, 'u_edgeLow');
this._uEdgeHigh = gl.getUniformLocation(this._program, 'u_edgeHigh');
// Resolve studio light uniform locations.
if (this._studioProgram) {
gl.useProgram(this._studioProgram);
this._uStCamera = gl.getUniformLocation(this._studioProgram, 'u_camera');
this._uStMask = gl.getUniformLocation(this._studioProgram, 'u_mask');
this._uStPrevMask = gl.getUniformLocation(this._studioProgram, 'u_prevMask');
this._uStTemporalRatio = gl.getUniformLocation(this._studioProgram, 'u_temporalRatio');
this._uStEdgeLow = gl.getUniformLocation(this._studioProgram, 'u_edgeLow');
this._uStEdgeHigh = gl.getUniformLocation(this._studioProgram, 'u_edgeHigh');
this._uStMaskTexelSize = gl.getUniformLocation(this._studioProgram, 'u_maskTexelSize');
this._uStBgDimming = gl.getUniformLocation(this._studioProgram, 'u_bgDimming');
this._uStBrightness = gl.getUniformLocation(this._studioProgram, 'u_brightness');
this._uStContrast = gl.getUniformLocation(this._studioProgram, 'u_contrast');
this._uStGlowIntensity = gl.getUniformLocation(this._studioProgram, 'u_glowIntensity');
this._uStSaturation = gl.getUniformLocation(this._studioProgram, 'u_saturation');
this._uStSkinSmoothing = gl.getUniformLocation(this._studioProgram, 'u_skinSmoothing');
this._uStToneRGB = gl.getUniformLocation(this._studioProgram, 'u_toneRGB');
this._uStCameraTexelSize = gl.getUniformLocation(this._studioProgram, 'u_cameraTexelSize');
}
// Allocate textures
this._texCamera = createTexture(gl);
this._texBackground = createTexture(gl);
this._texMask = createTexture(gl);
this._texMaskB = createTexture(gl);
logger.info('[VirtualBackground] WebGLCompositor: initialised');
}
/**
* Handles WebGL context loss.
*
* @param {Event} event - The webglcontextlost event.
* @private
* @returns {void}
*/
_onContextLost(event: Event): void {
event.preventDefault();
logger.warn('[VirtualBackground] WebGLCompositor: context lost');
// The browser automatically destroys all GL objects on context loss.
// Null the JS references so the GC can collect the wrapper objects.
this._gl = null;
this._program = null;
this._studioProgram = null;
this._quadBuffer = null;
this._texCamera = null;
this._texBackground = null;
this._texMask = null;
this._texMaskB = null;
}
/**
* Handles WebGL context restoration.
*
* @private
* @returns {void}
*/
_onContextRestored(): void {
logger.info('[VirtualBackground] WebGLCompositor: context restored — reinitialising');
this._init();
}
/**
* Re-binds the quad vertex buffer and re-specifies vertex attribute pointers.
*
* When the GL context is shared with TF.js, TF.js inference overwrites the
* global vertex attribute state (bound ARRAY_BUFFER + vertexAttribPointer
* descriptors). Calling this before every drawArrays() restores our state
* without re-querying attribute locations.
*
* @private
* @param {WebGLRenderingContext} gl - Active GL context.
* @returns {void}
*/
_bindQuadState(gl: WebGLRenderingContext): void {
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer);
gl.enableVertexAttribArray(this._aPosition);
gl.vertexAttribPointer(this._aPosition, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(this._aTexCoord);
gl.vertexAttribPointer(this._aTexCoord, 2, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
}
}

View File

@@ -5,15 +5,18 @@ import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { timeout } from '../../virtual-background/functions';
import logger from '../../virtual-background/logger';
import { DeviceTier, detectDeviceTier } from './DeviceTierDetector.web';
import JitsiStreamBackgroundEffect, { IBackgroundEffectOptions } from './JitsiStreamBackgroundEffect';
import JitsiStreamBackgroundEffectV2 from './JitsiStreamBackgroundEffectV2';
// @ts-ignore
import createTFLiteModule from './vendor/tflite/tflite';
// @ts-ignore
import createTFLiteSIMDModule from './vendor/tflite/tflite-simd';
/* eslint-enable lines-around-comment */
const models = {
modelLandscape: 'libs/selfie_segmentation_landscape.tflite'
};
/* eslint-enable lines-around-comment */
let modelBuffer: ArrayBuffer;
let tflite: any;
@@ -28,16 +31,16 @@ const segmentationDimensions = {
};
/**
* Creates a new instance of JitsiStreamBackgroundEffect. This loads the Meet background model that is used to
* Creates a new V1 instance of JitsiStreamBackgroundEffect. This loads the Meet background model that is used to
* extract person segmentation.
*
* @param {Object} virtualBackground - The virtual object that contains the background image source and
* the isVirtualBackground flag that indicates if virtual image is activated.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffect>}
* @returns {Promise<JitsiStreamBackgroundEffect | undefined>}
*/
export async function createVirtualBackgroundEffect(virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']) {
async function createVirtualBackgroundEffectV1(virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']): Promise<JitsiStreamBackgroundEffect | undefined> {
if (!MediaStreamTrack.prototype.getSettings && !MediaStreamTrack.prototype.getConstraints) {
throw new Error('JitsiStreamBackgroundEffect not supported!');
}
@@ -104,3 +107,72 @@ export async function createVirtualBackgroundEffect(virtualBackground: IBackgrou
return new JitsiStreamBackgroundEffect(tflite, options);
}
/**
* Creates a new V2 instance of the virtual background effect.
*
* Device tier detection runs first and selects the appropriate backend. All tiers run inference
* inside a dedicated VBInferenceWorker: LOW tier uses TFLite WASM (selfie_segmenter FP16) and MEDIUM/HIGH
* tiers use TF.js with WebGL/WebGPU backends. Pre-detected capabilities are passed to the
* constructor so {@link JitsiStreamBackgroundEffectV2._initAsync} does not need to re-probe.
*
* @param {Object} virtualBackground - The virtual background options.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffectV2 | undefined>}
*/
async function createVirtualBackgroundEffectV2(
virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']
): Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined> {
const config = APP.store.getState()['features/base/config'];
let capabilities;
try {
capabilities = await detectDeviceTier(config);
} catch (err) {
logger.error('[VirtualBackground] Device tier detection failed', err);
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
return;
}
if (capabilities.tier === DeviceTier.UNSUPPORTED) {
logger.warn('[VirtualBackground] No supported GPU or WASM backend — virtual background unavailable');
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
return;
}
return new JitsiStreamBackgroundEffectV2(virtualBackground, config, capabilities);
}
/**
* Creates a new instance of the virtual background stream effect. Delegates to V1 (TFLite WASM) or
* V2 (MediaPipe body-segmentation + WebGL) based on the {@code virtualBackground.enableV2} config flag.
*
* @param {Object} virtualBackground - The virtual object that contains the background image source and
* the isVirtualBackground flag that indicates if virtual image is activated.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined>}
*/
export async function createVirtualBackgroundEffect(
virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']
): Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined> {
const config = APP.store.getState()['features/base/config'];
if (config.virtualBackground?.enableV2) {
logger.info('[VirtualBackground] Using V2 engine (MediaPipe body-segmentation + WebGL)');
return createVirtualBackgroundEffectV2(virtualBackground, dispatch);
}
logger.info('[VirtualBackground] Using V1 engine (TFLite WASM)');
return createVirtualBackgroundEffectV1(virtualBackground, dispatch);
}

View File

@@ -1,8 +1,10 @@
import { IStore } from '../app/types';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { createVirtualBackgroundEffect } from '../stream-effects/virtual-background';
import { BACKGROUND_ENABLED, SET_VIRTUAL_BACKGROUND } from './actionTypes';
import { VIRTUAL_BACKGROUND_TYPE } from './constants';
import { STUDIO_LIGHT_PRESETS, VIRTUAL_BACKGROUND_TYPE } from './constants';
import logger from './logger';
import { IVirtualBackground } from './reducer';
@@ -15,6 +17,9 @@ import { IVirtualBackground } from './reducer';
*/
export function toggleBackgroundEffect(options: IVirtualBackground, jitsiTrack: any) {
return async function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
// Snapshot state before mutations so we can restore it if the effect fails to init.
const prevBackground = getState()['features/virtual-background'];
dispatch(backgroundEnabled(options.backgroundEffectEnabled));
dispatch(setVirtualBackground(options));
const state = getState();
@@ -23,14 +28,39 @@ export function toggleBackgroundEffect(options: IVirtualBackground, jitsiTrack:
if (jitsiTrack) {
try {
if (options.backgroundEffectEnabled) {
await jitsiTrack.setEffect(await createVirtualBackgroundEffect(virtualBackground, dispatch));
const effect = await createVirtualBackgroundEffect(virtualBackground, dispatch);
await jitsiTrack.setEffect(effect);
// V2 effects initialise asynchronously (worker spawn + model load). Await the
// init promise so failures propagate here instead of being silently swallowed.
if (effect && (effect as any).initPromise instanceof Promise) {
await (effect as any).initPromise;
}
} else {
await jitsiTrack.setEffect(undefined);
dispatch(backgroundEnabled(false));
}
} catch (error) {
dispatch(backgroundEnabled(false));
logger.error('Error on apply background effect:', error);
// Revert Redux state to the values that were active before the failed toggle.
dispatch(backgroundEnabled(prevBackground.backgroundEffectEnabled));
dispatch(setVirtualBackground(prevBackground));
// Remove any partially-applied effect so the video track is restored.
if (options.backgroundEffectEnabled) {
try {
await jitsiTrack.setEffect(undefined);
} catch (cleanupErr) {
logger.warn('[VirtualBackground] Failed to clear effect after init failure:', cleanupErr);
}
}
dispatch(showWarningNotification(
{ titleKey: 'virtualBackground.backgroundEffectError' },
NOTIFICATION_TIMEOUT_TYPE.LONG
));
}
}
};
@@ -53,7 +83,8 @@ export function setVirtualBackground(options?: IVirtualBackground) {
virtualSource: options?.virtualSource,
blurValue: options?.blurValue,
backgroundType: options?.backgroundType,
selectedThumbnail: options?.selectedThumbnail
selectedThumbnail: options?.selectedThumbnail,
studioLightOptions: options?.studioLightOptions
};
}
@@ -103,3 +134,36 @@ export function toggleBlurredBackgroundEffect(videoTrack: any, blurType: 'slight
}
};
}
/**
* Toggles studio light effect on the given video track. Used by the external API.
*
* @param {Object} videoTrack - The targeted video track.
* @param {boolean} enabled - Whether to enable or disable studio light.
* @param {string} [preset] - Preset name ('natural', 'spotlight', 'soft-focus'). Defaults to 'natural'.
* @returns {Function}
*/
export function toggleStudioLightEffect(videoTrack: any, enabled: boolean, preset?: string) {
return async function(dispatch: IStore['dispatch']) {
if (!videoTrack) {
return;
}
if (!enabled) {
dispatch(toggleBackgroundEffect({
backgroundEffectEnabled: false,
selectedThumbnail: 'none'
}, videoTrack));
} else {
const presetKey = preset ?? 'natural';
const presetOptions = STUDIO_LIGHT_PRESETS[presetKey] ?? STUDIO_LIGHT_PRESETS.natural;
dispatch(toggleBackgroundEffect({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.STUDIO_LIGHT,
selectedThumbnail: `studio-light-${presetKey}`,
studioLightOptions: presetOptions
}, videoTrack));
}
};
}

View File

@@ -16,7 +16,14 @@ import Tooltip from '../../base/tooltip/components/Tooltip';
import Spinner from '../../base/ui/components/web/Spinner';
import { showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { BACKGROUNDS_LIMIT, IMAGES, type Image, VIRTUAL_BACKGROUND_TYPE } from '../constants';
import {
BACKGROUNDS_LIMIT,
IMAGES,
type Image,
STUDIO_LIGHT_PRESETS,
StudioLightPreset,
VIRTUAL_BACKGROUND_TYPE
} from '../constants';
import { toDataURL } from '../functions';
import logger from '../logger';
import { IVirtualBackground } from '../reducer';
@@ -27,6 +34,11 @@ import VirtualBackgroundPreview from './VirtualBackgroundPreview';
interface IProps extends WithTranslation {
/**
* Whether studio light presets should be shown (V2 enabled).
*/
_enableStudioLight: boolean;
/**
* The list of Images to choose from.
*/
@@ -171,6 +183,36 @@ const useStyles = makeStyles()(theme => {
[[ '&:hover', '&:focus' ] as any]: {
display: 'block'
}
},
studioLightSection: {
marginTop: theme.spacing(2),
width: '100%'
},
studioLightLabel: {
...theme.typography.labelBold,
color: theme.palette.text01,
marginBottom: theme.spacing(1)
},
studioLightGrid: {
width: '100%',
display: 'inline-grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: theme.spacing(1)
},
studioLightNatural: {
background: 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
},
studioLightSpotlight: {
background: 'radial-gradient(circle at 50% 40%, #e0e0e0 0%, #2a2a2a 70%)'
},
studioLightSoftFocus: {
background: 'linear-gradient(135deg, #fce4ec, #f8bbd0, #ffffff)'
}
};
});
@@ -181,6 +223,7 @@ const useStyles = makeStyles()(theme => {
* @returns {ReactElement}
*/
function VirtualBackgrounds({
_enableStudioLight,
_images,
_showUploadButton,
onOptionsChange,
@@ -335,11 +378,59 @@ function VirtualBackgrounds({
await setPreviewIsLoaded(loaded);
}, []);
const _applyStudioLightPreset = useCallback((preset: string) => {
const presetOptions = STUDIO_LIGHT_PRESETS[preset] ?? STUDIO_LIGHT_PRESETS.natural;
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.STUDIO_LIGHT,
selectedThumbnail: `studio-light-${preset}`,
studioLightOptions: presetOptions
});
logger.info(`Studio light preset "${preset}" set for virtual background preview!`);
}, []);
const selectStudioNatural = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.NATURAL);
}, [ _applyStudioLightPreset ]);
const selectStudioNaturalKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioNatural();
}
}, [ selectStudioNatural ]);
const selectStudioSpotlight = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.SPOTLIGHT);
}, [ _applyStudioLightPreset ]);
const selectStudioSpotlightKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioSpotlight();
}
}, [ selectStudioSpotlight ]);
const selectStudioSoftFocus = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.SOFT_FOCUS);
}, [ _applyStudioLightPreset ]);
const selectStudioSoftFocusKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioSoftFocus();
}
}, [ selectStudioSoftFocus ]);
// create a full list of {backgroundId: backgroundLabel} to easily retrieve label of selected background
const labelsMap: Record<string, string> = {
none: t('virtualBackground.none'),
'slight-blur': t('virtualBackground.slightBlur'),
blur: t('virtualBackground.blur'),
'studio-light-natural': t('virtualBackground.studioLightNatural'),
'studio-light-spotlight': t('virtualBackground.studioLightSpotlight'),
'studio-light-soft-focus': t('virtualBackground.studioLightSoftFocus'),
..._images.reduce<Record<string, string>>((acc, image) => {
acc[image.id] = image.tooltip ? t(`virtualBackground.${image.tooltip}`) : '';
@@ -483,6 +574,66 @@ function VirtualBackgrounds({
</div>
))}
</div>
{_enableStudioLight && (
<div className = { classes.studioLightSection }>
<div className = { classes.studioLightLabel }>
{t('virtualBackground.studioLightTitle')}
</div>
<div
aria-label = { t('virtualBackground.studioLightTitle') }
className = { classes.studioLightGrid }
role = 'radiogroup'>
<Tooltip
content = { t('virtualBackground.studioLightNatural') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-natural') }
aria-label = { t('virtualBackground.studioLightNatural') }
className = { cx(classes.thumbnail,
classes.studioLightNatural,
getSelectedThumbnailClass('studio-light-natural')) }
onClick = { selectStudioNatural }
onKeyPress = { selectStudioNaturalKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightNatural')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.studioLightSpotlight') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-spotlight') }
aria-label = { t('virtualBackground.studioLightSpotlight') }
className = { cx(classes.thumbnail,
classes.studioLightSpotlight,
getSelectedThumbnailClass('studio-light-spotlight')) }
onClick = { selectStudioSpotlight }
onKeyPress = { selectStudioSpotlightKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightSpotlight')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.studioLightSoftFocus') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-soft-focus') }
aria-label = { t('virtualBackground.studioLightSoftFocus') }
className = { cx(classes.thumbnail,
classes.studioLightSoftFocus,
getSelectedThumbnailClass('studio-light-soft-focus')) }
onClick = { selectStudioSoftFocus }
onKeyPress = { selectStudioSoftFocusKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightSoftFocus')}
</div>
</Tooltip>
</div>
</div>
)}
</div>
)}
</>
@@ -502,6 +653,7 @@ function _mapStateToProps(state: IReduxState) {
const hasBrandingImages = Boolean(dynamicBrandingImages.length);
return {
_enableStudioLight: Boolean(state['features/base/config'].virtualBackground?.enableV2),
_images: (hasBrandingImages && dynamicBrandingImages) || IMAGES,
_showUploadButton: !state['features/base/config'].disableAddingBackgroundImages
};

View File

@@ -4,9 +4,72 @@
* @enum {string}
*/
export const VIRTUAL_BACKGROUND_TYPE = {
IMAGE: 'image',
BLUR: 'blur',
NONE: 'none'
IMAGE: 'image',
NONE: 'none',
STUDIO_LIGHT: 'studio-light'
};
/**
* Studio light preset identifiers.
*
* @enum {string}
*/
export enum StudioLightPreset {
NATURAL = 'natural',
SOFT_FOCUS = 'soft-focus',
SPOTLIGHT = 'spotlight'
}
/**
* Studio light shader parameter defaults (used when no preset is selected).
*/
export const STUDIO_LIGHT_DEFAULTS = {
bgDimming: 0.0,
brightness: 0.06,
contrast: 1.08,
glowIntensity: 0.03,
preset: StudioLightPreset.NATURAL,
saturation: 1.0,
skinSmoothing: 0.1,
toneB: 1.0,
toneG: 1.0,
toneR: 1.0
};
/**
* Maps each preset name to its shader parameters.
*
* Natural: clean, minimal enhancement — barely noticeable, background untouched.
* Spotlight: face pops against dimmed background, warm tone, pro studio lighting feel.
* Soft Focus: beauty/glamour filter — skin smoothing, glow bloom, lower contrast.
*/
export const STUDIO_LIGHT_PRESETS: Record<string, typeof STUDIO_LIGHT_DEFAULTS> = {
[StudioLightPreset.NATURAL]: { ...STUDIO_LIGHT_DEFAULTS },
[StudioLightPreset.SOFT_FOCUS]: {
bgDimming: 0.0,
brightness: 0.04,
contrast: 0.92,
glowIntensity: 0.18,
preset: StudioLightPreset.SOFT_FOCUS,
saturation: 1.08,
skinSmoothing: 0.55,
toneB: 1.0,
toneG: 1.0,
toneR: 1.0
},
[StudioLightPreset.SPOTLIGHT]: {
bgDimming: 0.4,
brightness: 0.10,
contrast: 1.10,
glowIntensity: 0.03,
preset: StudioLightPreset.SPOTLIGHT,
saturation: 1.02,
skinSmoothing: 0.12,
toneB: 0.99,
toneG: 1.0,
toneR: 1.01
}
};

View File

@@ -5,11 +5,25 @@ import { BACKGROUND_ENABLED, SET_VIRTUAL_BACKGROUND } from './actionTypes';
const STORE_NAME = 'features/virtual-background';
export interface IStudioLightOptions {
bgDimming?: number;
brightness?: number;
contrast?: number;
glowIntensity?: number;
preset?: string;
saturation?: number;
skinSmoothing?: number;
toneB?: number;
toneG?: number;
toneR?: number;
}
export interface IVirtualBackground {
backgroundEffectEnabled?: boolean;
backgroundType?: string;
blurValue?: number;
selectedThumbnail?: string;
studioLightOptions?: IStudioLightOptions;
virtualSource?: string;
}
@@ -25,7 +39,8 @@ export interface IVirtualBackground {
* specified action.
*/
ReducerRegistry.register<IVirtualBackground>(STORE_NAME, (state = {}, action): IVirtualBackground => {
const { virtualSource, backgroundEffectEnabled, blurValue, backgroundType, selectedThumbnail } = action;
const { virtualSource, backgroundEffectEnabled, blurValue, backgroundType, selectedThumbnail,
studioLightOptions } = action;
/**
* Sets up the persistence of the feature {@code virtual-background}.
@@ -39,7 +54,8 @@ ReducerRegistry.register<IVirtualBackground>(STORE_NAME, (state = {}, action): I
virtualSource,
blurValue,
backgroundType,
selectedThumbnail
selectedThumbnail,
studioLightOptions
};
}
case BACKGROUND_ENABLED: {

View File

@@ -4,6 +4,9 @@ const VB_DIALOG_CONTAINER = '#virtual-background-dialog';
const NONE_THUMBNAIL = '[aria-label="Remove background"]';
const SLIGHT_BLUR_THUMBNAIL = '[aria-label="Half Blur"]';
const BLUR_THUMBNAIL = '[aria-label="Blur"]';
const STUDIO_NATURAL_THUMBNAIL = '[aria-label="Natural"]';
const STUDIO_SPOTLIGHT_THUMBNAIL = '[aria-label="Spotlight"]';
const STUDIO_SOFT_FOCUS_THUMBNAIL = '[aria-label="Soft Focus"]';
/**
* Page object for the Virtual Background tab inside the settings dialog.
@@ -101,6 +104,57 @@ export default class VirtualBackgroundDialog extends BaseDialog {
return this.isThumbnailChecked(BLUR_THUMBNAIL);
}
/**
* Clicks the "Natural" studio light preset thumbnail.
*/
clickStudioNatural(): Promise<void> {
return this.clickAndWaitChecked(STUDIO_NATURAL_THUMBNAIL, 'Studio Natural');
}
/**
* Clicks the "Spotlight" studio light preset thumbnail.
*/
clickStudioSpotlight(): Promise<void> {
return this.clickAndWaitChecked(STUDIO_SPOTLIGHT_THUMBNAIL, 'Studio Spotlight');
}
/**
* Clicks the "Soft Focus" studio light preset thumbnail.
*/
clickStudioSoftFocus(): Promise<void> {
return this.clickAndWaitChecked(STUDIO_SOFT_FOCUS_THUMBNAIL, 'Studio Soft Focus');
}
/**
* Returns true if the Natural studio light thumbnail is currently selected.
*/
isStudioNaturalChecked(): Promise<boolean> {
return this.isThumbnailChecked(STUDIO_NATURAL_THUMBNAIL);
}
/**
* Returns true if the Spotlight studio light thumbnail is currently selected.
*/
isStudioSpotlightChecked(): Promise<boolean> {
return this.isThumbnailChecked(STUDIO_SPOTLIGHT_THUMBNAIL);
}
/**
* Returns true if the Soft Focus studio light thumbnail is currently selected.
*/
isStudioSoftFocusChecked(): Promise<boolean> {
return this.isThumbnailChecked(STUDIO_SOFT_FOCUS_THUMBNAIL);
}
/**
* Returns true if any studio light thumbnails are visible in the dialog.
*/
async isStudioLightSectionVisible(): Promise<boolean> {
const el = this.participant.driver.$(STUDIO_NATURAL_THUMBNAIL);
return el.isExisting();
}
/**
* Clicks OK to apply the selected background and close the dialog.
*/

View File

@@ -0,0 +1,433 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, hangupAllParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
const V2_CONFIG = {
disableVirtualBackground: false,
virtualBackground: {
enableV2: true
}
};
/**
* Reads the current virtual-background slice of the Redux store.
*/
async function getVBState() {
return ctx.p1.execute(() => APP.store.getState()['features/virtual-background']);
}
/**
* Reads the virtualBackground config from the Redux store.
*/
async function getVBConfig() {
return ctx.p1.execute(
() => APP.store.getState()['features/base/config'].virtualBackground);
}
/**
* Opens the virtual background dialog via the toolbar button and waits for it to be displayed.
*/
async function openVBDialog() {
await ctx.p1.getToolbar().clickVirtualBackgroundButton();
await ctx.p1.getVirtualBackgroundDialog().waitForDisplay();
}
/**
* Waits until backgroundEffectEnabled settles to the expected value.
* When expecting true, the value must remain stable for stabilityWindow ms to confirm the
* async setEffect() did not roll back.
*
* @param {boolean} expected - The expected settled value.
* @param {number} timeout - How long to wait in ms.
* @param {number} stabilityWindow - How long the expected value must remain unchanged (ms).
*/
async function waitForEffectEnabled(expected: boolean, timeout = 15000, stabilityWindow = 500): Promise<void> {
let matchedAt: number | undefined;
const requiredStableMs = expected ? stabilityWindow : 0;
await ctx.p1.driver.waitUntil(
async () => {
const state = await getVBState();
if (state.backgroundEffectEnabled !== expected) {
matchedAt = undefined;
return false;
}
if (requiredStableMs === 0) {
return true;
}
const now = Date.now();
if (matchedAt === undefined) {
matchedAt = now;
return false;
}
return now - matchedAt >= requiredStableMs;
},
{ timeout, timeoutMsg: `backgroundEffectEnabled did not settle to ${expected} within ${timeout}ms` }
);
}
describe('Virtual backgrounds V2 engine', () => {
it('joining the meeting with V2 enabled', async () => {
await ensureOneParticipant({
configOverwrite: V2_CONFIG
});
// Verify V2 config is active in the Redux store.
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
});
it('V2 dialog shows studio light presets', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
expect(await vbDialog.isDisplayed()).toBe(true);
expect(await vbDialog.isStudioLightSectionVisible()).toBe(true);
});
it('select blur with V2 engine — effect enables and redux state correct', async () => {
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
expect(await vbDialog.isBlurChecked()).toBe(true);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
expect(state.selectedThumbnail).toBe('blur');
});
it('select slight blur with V2 engine — redux state correct', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickSlightBlur();
expect(await vbDialog.isSlightBlurChecked()).toBe(true);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
expect(state.selectedThumbnail).toBe('slight-blur');
});
it('disable V2 background — redux state resets', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
expect(state.selectedThumbnail).toBe('none');
});
it('select studio light Natural — aria-checked and redux state', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioNatural();
expect(await vbDialog.isStudioNaturalChecked()).toBe(true);
expect(await vbDialog.isStudioSpotlightChecked()).toBe(false);
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(false);
expect(await vbDialog.isBlurChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('studio-light');
expect(state.selectedThumbnail).toBe('studio-light-natural');
});
it('select studio light Spotlight — aria-checked and redux state', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioSpotlight();
expect(await vbDialog.isStudioSpotlightChecked()).toBe(true);
expect(await vbDialog.isStudioNaturalChecked()).toBe(false);
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('studio-light');
expect(state.selectedThumbnail).toBe('studio-light-spotlight');
});
it('select studio light Soft Focus — aria-checked and redux state', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioSoftFocus();
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(true);
expect(await vbDialog.isStudioNaturalChecked()).toBe(false);
expect(await vbDialog.isStudioSpotlightChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('studio-light');
expect(state.selectedThumbnail).toBe('studio-light-soft-focus');
});
it('switch from studio light to blur — effect stays enabled', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
expect(await vbDialog.isBlurChecked()).toBe(true);
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.selectedThumbnail).toBe('blur');
});
it('cancel studio light selection — redux state unchanged', async () => {
const stateBefore = await getVBState();
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioNatural();
expect(await vbDialog.isStudioNaturalChecked()).toBe(true);
await vbDialog.cancel();
const stateAfter = await getVBState();
expect(stateAfter.selectedThumbnail).toBe(stateBefore.selectedThumbnail);
expect(stateAfter.backgroundEffectEnabled).toBe(stateBefore.backgroundEffectEnabled);
expect(stateAfter.backgroundType).toBe(stateBefore.backgroundType);
});
it('studio light persistence across reopen', async () => {
// Apply studio light Spotlight.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickStudioSpotlight();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(true);
// Reopen and verify Spotlight is still selected.
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
expect(await vbDialog.isStudioSpotlightChecked()).toBe(true);
expect(await vbDialog.isStudioNaturalChecked()).toBe(false);
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(false);
expect(await vbDialog.isBlurChecked()).toBe(false);
// Clean up.
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
});
it('rapid switching across V2 background types — only last selection applied', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
await vbDialog.clickStudioNatural();
await vbDialog.clickSlightBlur();
await vbDialog.clickStudioSpotlight();
await vbDialog.clickStudioSoftFocus();
expect(await vbDialog.isStudioSoftFocusChecked()).toBe(true);
expect(await vbDialog.isBlurChecked()).toBe(false);
expect(await vbDialog.isSlightBlurChecked()).toBe(false);
expect(await vbDialog.isStudioNaturalChecked()).toBe(false);
expect(await vbDialog.isStudioSpotlightChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.selectedThumbnail).toBe('studio-light-soft-focus');
expect(state.backgroundType).toBe('studio-light');
});
});
describe('Virtual backgrounds V2 with tier override', () => {
it('hangup and rejoin with tier override config', async () => {
await hangupAllParticipants();
});
it('joining with V2 + low tier override', async () => {
await ensureOneParticipant({
configOverwrite: {
...V2_CONFIG,
virtualBackground: {
...V2_CONFIG.virtualBackground,
tierOverride: 'low'
}
}
});
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
expect(vbConfig?.tierOverride).toBe('low');
});
it('blur works on low tier', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
});
it('studio light works on low tier', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioNatural();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('studio-light');
expect(state.selectedThumbnail).toBe('studio-light-natural');
});
it('disable effect on low tier — clean shutdown', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
});
});
describe('Virtual backgrounds V2 with insertable streams disabled', () => {
it('hangup and rejoin with captureStream config', async () => {
await hangupAllParticipants();
});
it('joining with V2 + captureStream fallback', async () => {
await ensureOneParticipant({
configOverwrite: {
...V2_CONFIG,
virtualBackground: {
...V2_CONFIG.virtualBackground,
useInsertableStreams: false
}
}
});
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
expect(vbConfig?.useInsertableStreams).toBe(false);
});
it('blur works on captureStream path', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
});
it('studio light works on captureStream path', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickStudioSpotlight();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('studio-light');
expect(state.selectedThumbnail).toBe('studio-light-spotlight');
});
it('disable and re-enable on captureStream path', async () => {
// Disable.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickNone();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(false);
// Re-enable with slight blur.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickSlightBlur();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
// Clean up.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickNone();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(false);
});
});

View File

@@ -297,7 +297,7 @@ function getDevServerConfig() {
static: {
directory: process.cwd(),
watch: {
ignored: file => file.endsWith('.log')
ignored: file => file.endsWith('.log') || file.includes('node_modules')
}
}
};
@@ -383,6 +383,16 @@ module.exports = (_env, argv) => {
...getBundleAnalyzerPlugin(analyzeBundle, 'face-landmarks-worker')
],
performance: getPerformanceHints(perfHintOptions, 1024 * 1024 * 2) },
{ ...config,
entry: {
'vb-inference-worker':
'./react/features/stream-effects/virtual-background/VBInferenceWorker.ts'
},
plugins: [
...config.plugins,
...getBundleAnalyzerPlugin(analyzeBundle, 'vb-inference-worker')
],
performance: getPerformanceHints(perfHintOptions, 1024 * 1024 * 10) },
{ ...config, /**
* The NoiseSuppressorWorklet is loaded in an audio worklet which doesn't have the same
* context as a normal window, (e.g. self/window is not defined).