snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,399 @@
import { IVirtualBackgroundAdvancedConfig } from '../../base/config/configType';
import { VIRTUAL_BACKGROUND_TYPE } from '../../virtual-background/constants';
import logger from '../../virtual-background/logger';
import { IVirtualBackground } from '../../virtual-background/reducer';
import { BackendType } from './DeviceTierDetector';
import WorkerSegmentationBackend from './backend/WorkerSegmentationBackend';
import { ICompositor } from './compositor/ICompositor';
/** Benchmark log interval in frames. */
const BENCHMARK_LOG_INTERVAL = 60;
/** Default temporal blend ratio — 75% toward previous blended mask. */
const DEFAULT_TEMPORAL_BLEND_RATIO = 0.75;
/**
* Default smoothstep lower threshold for the LOW tier (TFLite selfie_segmentation_landscape).
* The landscape model (MediaPipe 2021, 256x144) produces softer confidences than modern models:
* hair strands typically sit in [0.10, 0.35], body/face in [0.60, 0.95], and true background
* below 0.10. A floor of 0.10 keeps hair in the transition zone without admitting background.
*/
const DEFAULT_EDGE_LOW_TFLITE = 0.1;
/**
* Default smoothstep upper threshold for the LOW tier. The landscape model rarely saturates
* to 1.0 even on clear body pixels, so the upper edge sits at 0.50 — pixels with confidence
* above this are fully opaque.
*/
const DEFAULT_EDGE_HIGH_TFLITE = 0.5;
/**
* Default smoothstep lower threshold for MEDIUM/HIGH tiers (TF.js MediaPipe body-segmentation).
* Background pixels near edges are typically below 0.25 and hair/edge pixels fall in 0.28-0.65.
*/
const DEFAULT_EDGE_LOW_GPU = 0.28;
/**
* Default smoothstep upper threshold for MEDIUM/HIGH tiers. Pixels above this are fully opaque.
*/
const DEFAULT_EDGE_HIGH_GPU = 0.65;
/**
* Target Gaussian feathering radius expressed in camera-output pixels. The backend returns a mask
* at the tier's segmentation resolution. The compositor applies blur in mask-pixel space, so the
* radius is scaled proportionally to keep the physical blur width constant.
*/
const TARGET_BLUR_CAMERA_PX = 20;
/**
* Target feathering width for the LOW tier (overrides TARGET_BLUR_CAMERA_PX when the backend is
* TFLite). The 256x144 mask upscales ~5x to a 1280x720 output, so a wider feathering radius
* softens the upscale artefacts and helps hide the model's coarse hair boundary.
*/
const TARGET_BLUR_CAMERA_PX_TFLITE = 32;
/**
* Options for constructing a BackgroundFrameProcessor.
*/
interface IProcessorOptions {
backend: WorkerSegmentationBackend;
compositor: ICompositor;
vbConfig?: IVirtualBackgroundAdvancedConfig;
virtualBackground: IVirtualBackground;
}
/**
* Orchestrates per-frame segmentation and compositing.
*
* Called by the InsertableStreams pipeline (or the captureStream fallback loop in
* JitsiStreamBackgroundEffect) for every incoming camera frame. Internally it:
* 1. Pre-scales the frame to the backend's segmentation resolution.
* 2. Runs inference (with configurable stride/skip for CPU backends).
* 3. Applies exponential moving average (EMA) temporal smoothing to the mask.
* 4. Prepares the background canvas (blur or image).
* 5. Calls the compositor to produce the final output.
*
* Does NOT own the frame delivery loop or the output stream — that is the pipeline's job.
*/
/** Number of consecutive inference failures before firing the failure callback. */
const FAILURE_THRESHOLD = 30;
export default class BackgroundFrameProcessor {
_backend: WorkerSegmentationBackend;
_backgroundCanvas: HTMLCanvasElement;
_backgroundCtx: CanvasRenderingContext2D | null = null;
_benchmarkAccumTotal = 0;
_benchmarkFrameCount = 0;
_cachedMaskData: ImageData | null = null;
_compositor: ICompositor;
_consecutiveFailures = 0;
_edgeHigh: number;
_edgeLow: number;
_firstMaskFrame = true;
_frameCount = 0;
_inferenceSkipCounter = 0;
_isReady = false;
_maskAccumF32: Float32Array | null = null;
_options: IVirtualBackground;
_temporalBlendRatio: number;
_vbConfig?: IVirtualBackgroundAdvancedConfig;
_virtualImage: HTMLImageElement | null = null;
/**
* Called once when inference fails persistently (FAILURE_THRESHOLD consecutive failures).
* The processor switches to permanent passthrough after firing. Set by the effect owner
* to dispatch a UI notification and revert Redux state.
*/
onInferenceFailure: (() => void) | null = null;
/**
* Creates a new background frame processor.
*
* @param {IProcessorOptions} opts - Construction options.
*/
constructor(opts: IProcessorOptions) {
this._backend = opts.backend;
this._compositor = opts.compositor;
this._vbConfig = opts.vbConfig;
this._options = opts.virtualBackground;
this._temporalBlendRatio = opts.vbConfig?.temporalBlendRatio
?? DEFAULT_TEMPORAL_BLEND_RATIO;
// Tier-specific edge thresholds — only used by WebGL compositor (V2).
// V1 Canvas 2D path ignores these (mask is used as-is for alpha compositing).
const isTFLite = opts.backend.capabilities.backend === BackendType.TFLITE;
const defaultEdgeLow = isTFLite ? DEFAULT_EDGE_LOW_TFLITE : DEFAULT_EDGE_LOW_GPU;
const defaultEdgeHigh = isTFLite ? DEFAULT_EDGE_HIGH_TFLITE : DEFAULT_EDGE_HIGH_GPU;
this._edgeLow = opts.vbConfig?.edgeLow ?? defaultEdgeLow;
this._edgeHigh = opts.vbConfig?.edgeHigh ?? defaultEdgeHigh;
// Set up virtual image if needed.
if (opts.virtualBackground.backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE) {
this._virtualImage = document.createElement('img');
this._virtualImage.crossOrigin = 'anonymous';
this._virtualImage.src = opts.virtualBackground.virtualSource ?? '';
}
this._backgroundCanvas = document.createElement('canvas');
}
/**
* Initialises the backend (model loading, worker spawn, etc.). Idempotent — safe to call
* multiple times (e.g. pre-init in the factory then again in startEffect).
*
* @returns {Promise<void>} Resolves when ready for inference.
*/
async init(): Promise<void> {
if (this._isReady) {
return;
}
await this._backend.init();
this._isReady = true;
logger.debug('[BackgroundFrameProcessor] Backend init complete');
}
/**
* Processes one camera frame through segmentation and compositing.
*
* @param {CanvasImageSource} source - Current camera frame.
* @returns {Promise<HTMLCanvasElement | null>} Compositor output canvas, or null for passthrough.
*/
async processFrame(source: CanvasImageSource): Promise<HTMLCanvasElement | null> {
if (!this._isReady) {
return null;
}
const frameStart = performance.now();
let maskData: ImageData | null = null;
// CPU backends (TFLite) skip every Nth frame and reuse the cached mask.
// GPU tiers (MEDIUM/HIGH) run inference every frame.
const isCpuBackend = this._backend.capabilities.backend === BackendType.TFLITE;
const stride = isCpuBackend
? (this._vbConfig?.inferenceStride ?? 2)
: 1;
const runInference = this._inferenceSkipCounter % stride === 0;
this._inferenceSkipCounter++;
if (runInference) {
try {
const { segWidth, segHeight } = this._backend.capabilities;
const bitmap = await createImageBitmap(source, {
resizeHeight: segHeight,
resizeQuality: 'medium',
resizeWidth: segWidth
});
const fresh = await this._backend.infer(bitmap);
if (fresh) {
this._cachedMaskData = this._applyMaskEMA(fresh);
this._consecutiveFailures = 0;
} else {
this._consecutiveFailures++;
this._checkFailureThreshold();
}
} catch (err) {
logger.error('[BackgroundFrameProcessor] Inference error', err);
this._consecutiveFailures++;
this._checkFailureThreshold();
}
}
maskData = this._cachedMaskData;
this._frameCount++;
// Benchmark logging.
if (this._vbConfig?.testMode) {
this._benchmarkAccumTotal += performance.now() - frameStart;
this._benchmarkFrameCount++;
if (this._benchmarkFrameCount >= BENCHMARK_LOG_INTERVAL) {
this._logBenchmark();
}
}
if (!maskData) {
return null;
}
// Ensure compositor and background canvas match the source dimensions.
const srcWidth = (source as HTMLVideoElement).videoWidth
?? (source as VideoFrame).displayWidth
?? (source as HTMLVideoElement).width;
const srcHeight = (source as HTMLVideoElement).videoHeight
?? (source as VideoFrame).displayHeight
?? (source as HTMLVideoElement).height;
if (this._backgroundCanvas.width !== srcWidth
|| this._backgroundCanvas.height !== srcHeight) {
this._backgroundCanvas.width = srcWidth;
this._backgroundCanvas.height = srcHeight;
this._backgroundCtx = this._backgroundCanvas.getContext('2d');
this._compositor.resize(srcWidth, srcHeight);
}
// Prepare background canvas (blur or image).
this._prepareBackground(source);
// Composite. The TFLite tier gets a wider feathering radius because its 256x144 mask
// upscales by ~5x to the output and benefits from extra softening at the person boundary.
const targetBlurCameraPx = this._backend.capabilities.backend === BackendType.TFLITE
? TARGET_BLUR_CAMERA_PX_TFLITE
: TARGET_BLUR_CAMERA_PX;
const maskBlurRadius = Math.round(targetBlurCameraPx * maskData.width / srcWidth);
// V1 used blur(8px) for blur backgrounds, blur(4px) for image — match that behavior.
const isBlurBg = this._options.backgroundType === VIRTUAL_BACKGROUND_TYPE.BLUR;
const cssBlurFilter = isBlurBg ? 'blur(8px)' : 'blur(4px)';
this._compositor.composite(source, this._backgroundCanvas, maskData, {
cssBlurFilter,
edgeHigh: this._edgeHigh,
edgeLow: this._edgeLow,
maskBlurRadius
});
return this._compositor.outputCanvas;
}
/**
* Releases all resources held by the processor.
*
* @returns {void}
*/
dispose(): void {
this._compositor.dispose();
this._cachedMaskData = null;
this._consecutiveFailures = 0;
this._firstMaskFrame = true;
this._maskAccumF32 = null;
this._inferenceSkipCounter = 0;
this._isReady = false;
this._backgroundCtx = null;
this.onInferenceFailure = null;
}
/**
* Checks whether consecutive inference failures have exceeded the threshold. When they do,
* fires the {@code onInferenceFailure} callback once and switches to permanent passthrough.
*
* @private
* @returns {void}
*/
_checkFailureThreshold(): void {
if (this._consecutiveFailures >= FAILURE_THRESHOLD && this.onInferenceFailure) {
logger.error(
'[BackgroundFrameProcessor] Persistent inference failure'
+ ` (${FAILURE_THRESHOLD} consecutive) — switching to passthrough`
);
this.onInferenceFailure();
this.onInferenceFailure = null;
this._isReady = false;
}
}
/**
* Applies exponential moving average (EMA) temporal smoothing to the raw segmentation mask.
*
* Uses a Float32 accumulator to avoid per-step quantization from Uint8ClampedArray rounding.
* Modified in-place on the fresh ImageData returned by the backend each call.
*
* @private
* @param {ImageData} maskData - Raw segmentation mask from the backend.
* @returns {ImageData} The same ImageData with R and A channels replaced by the EMA value.
*/
_applyMaskEMA(maskData: ImageData): ImageData {
const n = maskData.width * maskData.height;
const decay = this._temporalBlendRatio;
const newWeight = 1 - decay;
if (!this._maskAccumF32 || this._maskAccumF32.length !== n || this._firstMaskFrame) {
// First frame or resolution change: seed accumulator from the raw mask.
this._maskAccumF32 = new Float32Array(n);
const raw = maskData.data;
for (let i = 0, j = 0; i < n; i++, j += 4) {
this._maskAccumF32[i] = raw[j]; // R = confidence * 255
}
this._firstMaskFrame = false;
return maskData;
}
const raw = maskData.data;
const acc = this._maskAccumF32;
for (let i = 0, j = 0; i < n; i++, j += 4) {
acc[i] = newWeight * raw[j] + decay * acc[i];
raw[j] = acc[i]; // R — what the compositor shader reads as .r
raw[j + 3] = acc[i]; // A — kept in sync for Canvas 2D fallback
}
return maskData;
}
/**
* Renders the background (blur or image) onto the background canvas.
*
* @private
* @param {CanvasImageSource} source - Camera frame source.
* @returns {void}
*/
_prepareBackground(source: CanvasImageSource): void {
const ctx = this._backgroundCtx;
if (!ctx) {
return;
}
ctx.filter = 'none';
if (this._options.backgroundType === VIRTUAL_BACKGROUND_TYPE.BLUR) {
ctx.filter = `blur(${this._options.blurValue ?? 8}px)`;
ctx.drawImage(source, 0, 0, this._backgroundCanvas.width, this._backgroundCanvas.height);
} else if (this._options.backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE && this._virtualImage) {
ctx.drawImage(this._virtualImage, 0, 0,
this._backgroundCanvas.width, this._backgroundCanvas.height);
} else {
ctx.clearRect(0, 0, this._backgroundCanvas.width, this._backgroundCanvas.height);
}
ctx.filter = 'none';
}
/**
* Logs accumulated benchmark data every BENCHMARK_LOG_INTERVAL frames.
*
* @private
* @returns {void}
*/
_logBenchmark(): void {
const count = this._benchmarkFrameCount;
if (count === 0) {
return;
}
const totalMs = this._benchmarkAccumTotal;
const avgTotal = (totalMs / count).toFixed(1);
const fps = (1000 / (totalMs / count)).toFixed(1);
// performance.memory is Chrome-only (non-standard).
const perfMem = (performance as unknown as { memory?: { usedJSHeapSize: number; }; }).memory;
const heapMB = perfMem
? (perfMem.usedJSHeapSize / 1024 / 1024).toFixed(1)
: 'N/A';
logger.debug(
'[BackgroundFrameProcessor] BENCHMARK'
+ ` avgTotal=${avgTotal}ms fps=${fps} heap=${heapMB}MB`
+ ` (frames ${this._frameCount - count + 1}-${this._frameCount})`
);
this._benchmarkAccumTotal = 0;
this._benchmarkFrameCount = 0;
}
}

View File

@@ -0,0 +1,154 @@
import { IVirtualBackgroundAdvancedConfig } 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'
}
export interface IDeviceCapabilities {
backend: BackendType;
segHeight: number;
segWidth: number;
targetFps: number;
tier: DeviceTier;
}
interface ITierProfile {
backend: BackendType;
segHeight: number;
segWidth: number;
targetFps: number;
}
const TIER_PROFILES: Record<DeviceTier, ITierProfile> = {
[DeviceTier.HIGH]: {
backend: BackendType.WEBGPU,
segHeight: 288,
segWidth: 512,
targetFps: 30
},
[DeviceTier.LOW]: {
backend: BackendType.TFLITE,
segHeight: 144,
segWidth: 256,
targetFps: 30
},
[DeviceTier.MEDIUM]: {
backend: BackendType.WEBGL,
segHeight: 216,
segWidth: 384,
targetFps: 30
}
};
/**
* Probes hardware capabilities (WebGPU, WebGL) and returns the detected tier.
* Falls back to LOW when no GPU is available — TFLite WASM always works as the floor.
*
* @returns {Promise<DeviceTier>} Detected device tier.
*/
async function probeHardwareTier(): Promise<DeviceTier> {
try {
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
logger.debug('[VirtualBackground] WebGPU available');
return DeviceTier.HIGH;
}
}
} catch {
// WebGPU unavailable.
}
const testCanvas = document.createElement('canvas');
const gl2 = testCanvas.getContext('webgl2');
if (gl2) {
gl2.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL2 available');
return DeviceTier.MEDIUM;
}
const gl1 = testCanvas.getContext('webgl');
if (gl1) {
gl1.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL1 available');
return DeviceTier.MEDIUM;
}
logger.debug('[VirtualBackground] No GPU — using TFLite WASM');
return DeviceTier.LOW;
}
/**
* Returns the device capabilities for virtual background processing.
*
* Delegates hardware detection to {@link probeHardwareTier} (which tests WebGPU, then
* WebGL2/WebGL1, and falls back to LOW/TFLite), then applies config overrides on top of
* the detected tier: {@code tierOverride} swaps the entire tier profile, and
* {@code segmentationWidth}, {@code segmentationHeight}, and {@code targetFps} override
* individual fields.
*
* @param {IVirtualBackgroundAdvancedConfig} vbConfig - Virtual background advanced configuration.
* @returns {Promise<IDeviceCapabilities>}
*/
export async function detectDeviceTier(
vbConfig?: IVirtualBackgroundAdvancedConfig): Promise<IDeviceCapabilities> {
const hardwareTier = await probeHardwareTier();
logger.info(`[VirtualBackground] Hardware tier: ${hardwareTier}`);
const profile = TIER_PROFILES[hardwareTier];
const caps: IDeviceCapabilities = {
...profile,
tier: hardwareTier
};
// Apply config overrides.
if (vbConfig) {
if (vbConfig.tierOverride && vbConfig.tierOverride !== caps.tier) {
const overrideTier = vbConfig.tierOverride as DeviceTier;
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;
logger.debug(`[VirtualBackground] Config override: tier forced to ${overrideTier}`);
}
}
if (typeof vbConfig.segmentationWidth === 'number') {
caps.segWidth = vbConfig.segmentationWidth;
logger.debug(`[VirtualBackground] Config override: segmentationWidth = ${caps.segWidth}`);
}
if (typeof vbConfig.segmentationHeight === 'number') {
caps.segHeight = vbConfig.segmentationHeight;
logger.debug(`[VirtualBackground] Config override: segmentationHeight = ${caps.segHeight}`);
}
if (typeof vbConfig.targetFps === 'number') {
caps.targetFps = vbConfig.targetFps;
logger.debug(`[VirtualBackground] Config override: targetFps = ${caps.targetFps}`);
}
}
return caps;
}

View File

@@ -0,0 +1,455 @@
import { IVirtualBackgroundAdvancedConfig } from '../../base/config/configType';
import { VIRTUAL_BACKGROUND_TYPE } from '../../virtual-background/constants';
import logger from '../../virtual-background/logger';
import { IVirtualBackground } from '../../virtual-background/reducer';
import BackgroundFrameProcessor from './BackgroundFrameProcessor';
import { IDeviceCapabilities } from './DeviceTierDetector';
import WorkerSegmentationBackend from './backend/WorkerSegmentationBackend';
import Canvas2DCompositor from './compositor/Canvas2DCompositor';
import { ICompositor } from './compositor/ICompositor';
import WebGLCompositor from './compositor/WebGLCompositor';
import InsertableStreamsPipeline from './pipeline/InsertableStreamsPipeline';
import {
CLEAR_TIMEOUT,
SET_TIMEOUT,
TIMEOUT_TICK,
timerWorkerScript
} from './workers/TimerWorker';
/**
* Segmentation canvas dimensions for the V1 landscape TFLite model. Fixed by the model's
* input tensor shape — the V1 path always runs inference on a 256x144 canvas.
*/
const V1_SEG_WIDTH = 256;
const V1_SEG_HEIGHT = 144;
/**
* V2-specific constructor options — bundled so the V1 positional signature stays unchanged.
* Presence of this object on the constructor is the V1/V2 discriminator.
*/
export interface IV2EffectInit {
capabilities: IDeviceCapabilities;
enableV2: boolean;
vbConfig?: IVirtualBackgroundAdvancedConfig;
}
/**
* Virtual background stream effect.
*
* When {@code enableV2} is false, the class behaves identically to the original V1
* engine: main-thread TFLite WASM inference, Canvas 2D compositing, TimerWorker frame driver.
*
* When {@code enableV2} is true (default), the constructor delegates to the
* pipeline/backend/compositor abstraction: Worker-based inference, WebGL compositing, and
* insertable streams (with captureStream fallback). The captureStream fallback reuses the
* shared video/canvas/timer.
*/
export default class JitsiStreamBackgroundEffect {
_backend: WorkerSegmentationBackend | null = null;
_enableV2: boolean;
_inputVideoElement: HTMLVideoElement;
_maskFrameTimerWorker: Worker | null = null;
_model: any;
_options: { height: number; virtualBackground: IVirtualBackground; width: number; };
_outputCanvasCtx: CanvasRenderingContext2D | null = null;
_outputCanvasElement: HTMLCanvasElement;
_pipeline: InsertableStreamsPipeline | null = null;
_processor: BackgroundFrameProcessor | null = null;
_segmentationMask: ImageData;
_segmentationMaskCanvas: HTMLCanvasElement;
_segmentationMaskCtx: CanvasRenderingContext2D | null;
_segmentationPixelCount: number;
_stream: any;
_virtualImage: HTMLImageElement;
_virtualVideo: HTMLVideoElement;
/**
* Resolves when V2 async initialization completes. V1 resolves immediately.
*/
initPromise: Promise<void> = Promise.resolve();
/**
* Called once when V2 inference fails persistently mid-session.
*
* @param {Function} cb - Failure callback, or null to clear.
*/
set onInferenceFailure(cb: (() => void) | null) {
if (this._processor) {
this._processor.onInferenceFailure = cb;
}
}
/**
* Creates a new background effect instance.
*
* @param {Object} model - Loaded TFLite WASM module (V1) or undefined (V2).
* @param {IVirtualBackground} virtualBackground - Active virtual background Redux slice
* (background type, blur value, image source).
* @param {IV2EffectInit} [v2Init] - V2-specific setup payload. When present, activates
* the V2 pipeline.
*/
constructor(
model: any,
virtualBackground: IVirtualBackground,
v2Init?: IV2EffectInit) {
// Workaround for FF issue https://bugzilla.mozilla.org/show_bug.cgi?id=1388974
this._outputCanvasElement = document.createElement('canvas');
this._outputCanvasElement.getContext('2d');
this._inputVideoElement = document.createElement('video');
this._enableV2 = Boolean(v2Init?.enableV2);
if (this._enableV2 && v2Init) {
const { capabilities, vbConfig } = v2Init;
this._backend = new WorkerSegmentationBackend(capabilities);
let compositor: ICompositor;
const webglCanvas = document.createElement('canvas');
const webglCompositor = new WebGLCompositor(webglCanvas);
if (webglCompositor.isAvailable) {
compositor = webglCompositor;
} else {
logger.debug('[VirtualBackground] WebGL unavailable — using Canvas 2D fallback');
compositor = new Canvas2DCompositor();
}
this._processor = new BackgroundFrameProcessor({
backend: this._backend,
compositor,
vbConfig,
virtualBackground
});
const useIS = vbConfig?.useInsertableStreams !== false
&& InsertableStreamsPipeline.isSupported();
// V2 with insertable streams uses the dedicated pipeline. Without IS, _pipeline
// stays null and startEffect falls back to the shared _setupCaptureStream +
// _startTimerLoop path, still driving _processor (worker + WebGL) every tick.
if (useIS) {
this._pipeline = new InsertableStreamsPipeline();
}
logger.debug(
`[VirtualBackground] V2 effect created — backend: ${this._backend.capabilities.backend}`
+ `, pipeline: ${useIS ? 'insertable-streams' : 'capture-stream'}`
+ `, compositor: ${compositor instanceof WebGLCompositor ? 'webgl' : 'canvas-2d'}`
);
return;
}
this._options = {
height: V1_SEG_HEIGHT,
virtualBackground,
width: V1_SEG_WIDTH
};
this._model = model;
this._segmentationPixelCount = V1_SEG_WIDTH * V1_SEG_HEIGHT;
if (virtualBackground.backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE) {
this._virtualImage = document.createElement('img');
this._virtualImage.crossOrigin = 'anonymous';
this._virtualImage.src = virtualBackground.virtualSource ?? '';
}
// Bind event handler so it is only bound once for every instance.
this._onMaskFrameTimer = this._onMaskFrameTimer.bind(this);
}
/**
* Checks if the local track supports this effect.
*
* @param {JitsiLocalTrack} jitsiLocalTrack - Track to apply effect.
* @returns {boolean} True if this effect can run on this track.
*/
isEnabled(jitsiLocalTrack: any) {
return jitsiLocalTrack.isVideoTrack() && jitsiLocalTrack.videoType === 'camera';
}
/**
* Starts the effect on the given camera stream.
*
* @param {MediaStream} stream - Stream to be used for processing.
* @returns {MediaStream} The stream with the applied effect.
*/
startEffect(stream: MediaStream) {
if (this._enableV2 && this._pipeline && this._processor) {
this.initPromise = this._processor.init();
this.initPromise.catch(() => undefined);
return this._pipeline.start(stream, this._processor);
}
const captureStream = this._setupCaptureStream(stream);
if (this._enableV2 && this._processor) {
this.initPromise = this._processor.init();
this.initPromise.catch(() => undefined);
this._startTimerLoop(async () => {
const result = await this._processor?.processFrame(this._inputVideoElement);
if (result && this._outputCanvasCtx) {
this._outputCanvasCtx.drawImage(result, 0, 0);
} else if (this._outputCanvasCtx) {
this._outputCanvasCtx.drawImage(
this._inputVideoElement, 0, 0,
this._outputCanvasElement.width, this._outputCanvasElement.height
);
}
});
} else {
this._segmentationMask = new ImageData(this._options.width, this._options.height);
this._segmentationMaskCanvas = document.createElement('canvas');
this._segmentationMaskCanvas.width = this._options.width;
this._segmentationMaskCanvas.height = this._options.height;
this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d');
this._startTimerLoop(() => this._renderMask());
}
return captureStream;
}
/**
* Stops the frame pipeline. Keeps the V2 processor + backend alive so the next
* startEffect (e.g. on unmute) can process frames immediately without a re-init
* window in which raw camera frames would be passed through.
*
* @returns {void}
*/
stopEffect() {
if (this._enableV2 && this._pipeline) {
this._pipeline.stop().catch(() => undefined);
} else {
this._stopTimerLoop();
this._inputVideoElement.onloadeddata = null;
this._inputVideoElement.srcObject = null;
}
}
/**
* Pre-initialises the V2 backend. No-op for V1 (model is loaded in the factory).
*
* @returns {Promise<void>}
*/
async init(): Promise<void> {
if (this._processor) {
await this._processor.init();
}
}
/**
* Sets up the video element and output canvas for captureStream-based frame processing.
* Used by both V1 and V2 captureStream paths.
*
* @private
* @param {MediaStream} stream - Camera stream.
* @returns {MediaStream} The captureStream output.
*/
_setupCaptureStream(stream: MediaStream): MediaStream {
this._stream = stream;
const firstVideoTrack = stream.getVideoTracks()[0];
const { height, frameRate, width }
= firstVideoTrack.getSettings ? firstVideoTrack.getSettings() : firstVideoTrack.getConstraints();
this._outputCanvasElement.width = parseInt(String(width), 10);
this._outputCanvasElement.height = parseInt(String(height), 10);
this._outputCanvasCtx = this._outputCanvasElement.getContext('2d');
this._inputVideoElement.width = parseInt(String(width), 10);
this._inputVideoElement.height = parseInt(String(height), 10);
this._inputVideoElement.autoplay = true;
this._inputVideoElement.srcObject = stream;
// autoplay is unreliable for out-of-DOM elements on srcObject reassignment.
this._inputVideoElement.play().catch(() => undefined);
return this._outputCanvasElement.captureStream(parseInt(String(frameRate), 10));
}
/**
* Starts a TimerWorker that calls the given callback on each tick. The next tick is
* scheduled after the callback completes (sequential). Used by both V1 and V2.
*
* @private
* @param {Function} onTick - Callback to invoke on each timer tick.
* @returns {void}
*/
_startTimerLoop(onTick: () => void | Promise<void>): void {
this._maskFrameTimerWorker = new Worker(timerWorkerScript, { name: 'VirtualBackground timer' });
this._maskFrameTimerWorker.onmessage = (response: MessageEvent) => {
if (response.data.id === TIMEOUT_TICK) {
Promise.resolve(onTick()).then(() => {
this._maskFrameTimerWorker?.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30
});
});
}
};
if (this._inputVideoElement.readyState >= 2) {
this._maskFrameTimerWorker.postMessage({ id: SET_TIMEOUT, timeMs: 1000 / 30 });
} else {
this._inputVideoElement.onloadeddata = () => {
this._maskFrameTimerWorker?.postMessage({ id: SET_TIMEOUT, timeMs: 1000 / 30 });
};
}
}
/**
* Stops and terminates the timer worker.
*
* @private
* @returns {void}
*/
_stopTimerLoop(): void {
if (this._maskFrameTimerWorker) {
this._maskFrameTimerWorker.postMessage({ id: CLEAR_TIMEOUT });
this._maskFrameTimerWorker.terminate();
this._maskFrameTimerWorker = null;
}
}
/**
* EventHandler onmessage for the maskFrameTimerWorker WebWorker.
*
* @private
* @param {EventHandler} response - The onmessage EventHandler parameter.
* @returns {void}
*/
_onMaskFrameTimer(response: { data: { id: number; }; }) {
if (response.data.id === TIMEOUT_TICK) {
this._renderMask();
}
}
/**
* Represents the run post processing.
*
* @returns {void}
*/
runPostProcessing() {
const track = this._stream.getVideoTracks()[0];
const { height, width } = track.getSettings() ?? track.getConstraints();
const { backgroundType } = this._options.virtualBackground;
if (!this._outputCanvasCtx) {
return;
}
this._outputCanvasElement.height = height;
this._outputCanvasElement.width = width;
this._outputCanvasCtx.globalCompositeOperation = 'copy';
// Draw segmentation mask.
// Smooth out the edges.
this._outputCanvasCtx.filter = backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE ? 'blur(4px)' : 'blur(8px)';
this._outputCanvasCtx?.drawImage( // @ts-ignore
this._segmentationMaskCanvas,
0,
0,
this._options.width,
this._options.height,
0,
0,
this._inputVideoElement.width,
this._inputVideoElement.height
);
this._outputCanvasCtx.globalCompositeOperation = 'source-in';
this._outputCanvasCtx.filter = 'none';
// Draw the foreground video.
// @ts-ignore
this._outputCanvasCtx?.drawImage(this._inputVideoElement, 0, 0);
// Draw the background.
this._outputCanvasCtx.globalCompositeOperation = 'destination-over';
if (backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE) {
this._outputCanvasCtx?.drawImage( // @ts-ignore
backgroundType === VIRTUAL_BACKGROUND_TYPE.IMAGE
? this._virtualImage : this._virtualVideo,
0,
0,
this._outputCanvasElement.width,
this._outputCanvasElement.height
);
} else {
this._outputCanvasCtx.filter = `blur(${this._options.virtualBackground.blurValue}px)`;
// @ts-ignore
this._outputCanvasCtx?.drawImage(this._inputVideoElement, 0, 0);
}
}
/**
* Represents the run Tensorflow Interference.
*
* @returns {void}
*/
runInference() {
this._model._runInference();
const outputMemoryOffset = this._model._getOutputMemoryOffset() / 4;
for (let i = 0; i < this._segmentationPixelCount; i++) {
const person = this._model.HEAPF32[outputMemoryOffset + i];
// Sets only the alpha component of each pixel.
this._segmentationMask.data[(i * 4) + 3] = 255 * person;
}
this._segmentationMaskCtx?.putImageData(this._segmentationMask, 0, 0);
}
/**
* Loop function to render the background mask.
*
* @private
* @returns {void}
*/
_renderMask() {
this.resizeSource();
this.runInference();
this.runPostProcessing();
}
/**
* Represents the resize source process.
*
* @returns {void}
*/
resizeSource() {
this._segmentationMaskCtx?.drawImage( // @ts-ignore
this._inputVideoElement,
0,
0,
this._inputVideoElement.width,
this._inputVideoElement.height,
0,
0,
this._options.width,
this._options.height
);
const imageData = this._segmentationMaskCtx?.getImageData(
0,
0,
this._options.width,
this._options.height
);
const inputMemoryOffset = this._model._getInputMemoryOffset() / 4;
for (let i = 0; i < this._segmentationPixelCount; i++) {
this._model.HEAPF32[inputMemoryOffset + (i * 3)] = Number(imageData?.data[i * 4]) / 255;
this._model.HEAPF32[inputMemoryOffset + (i * 3) + 1] = Number(imageData?.data[(i * 4) + 1]) / 255;
this._model.HEAPF32[inputMemoryOffset + (i * 3) + 2] = Number(imageData?.data[(i * 4) + 2]) / 255;
}
}
}

View File

@@ -0,0 +1,241 @@
import { getBaseUrl } from '../../../base/util/helpers';
import logger from '../../../virtual-background/logger';
import { BackendType, IDeviceCapabilities } from '../DeviceTierDetector';
/**
* Worker-based segmentation backend.
*
* Wraps the {@link VBInferenceWorker} message protocol. The worker runs TF.js body-segmentation
* (MEDIUM/HIGH tiers via WebGL/WebGPU) or TFLite WASM (LOW tier via selfie_segmentation_landscape)
* in a dedicated Web Worker thread.
*
* The worker may fall back from a GPU backend to TFLite if the GPU context is unavailable inside
* the Worker. When this happens, the capabilities are updated to reflect the actual backend and
* segmentation dimensions.
*
* EMA temporal smoothing is NOT applied here — that is the processor's responsibility. The backend
* returns raw mask ImageData from each inference call.
*/
export default class WorkerSegmentationBackend {
_capabilities: IDeviceCapabilities;
_pendingInferHandler: ((e: MessageEvent) => void) | null = null;
_pendingInferResolve: ((value: ImageData | null) => void) | null = null;
_worker: Worker | null = null;
_workerReady = false;
/**
* Creates a new worker-based segmentation backend.
*
* @param {IDeviceCapabilities} capabilities - Detected device capabilities.
*/
constructor(capabilities: IDeviceCapabilities) {
this._capabilities = { ...capabilities };
}
/**
* Returns the device capabilities for this backend.
*
* @returns {IDeviceCapabilities} The capabilities (may be updated after worker fallback).
*/
get capabilities(): IDeviceCapabilities {
return this._capabilities;
}
/**
* Creates the inference worker and sends the init message.
*
* The worker is loaded via an importScripts blob URL pattern (same approach as
* FaceLandmarksDetector) to load the pre-built worker bundle from /libs/ without a separate
* module-worker instantiation.
*
* Resolves when the worker signals init_done. If the worker falls back to a different backend,
* capabilities are updated to reflect the actual backend and segmentation dimensions.
*
* @returns {Promise<void>} Resolves when the worker is ready for inference.
* @throws {Error} When the worker signals init_error.
*/
async init(): Promise<void> {
const base = getBaseUrl().replace(/\/?$/, '/');
const workerUrl = `${base}libs/vb-inference-worker.min.js`;
// JSON.stringify escapes quotes, backslashes, and control chars in workerUrl.
const scriptSource = `importScripts(${JSON.stringify(workerUrl)});`;
// @ts-ignore — @types/node's BlobOptions / URL shadow DOM BlobPropertyBag / URL here.
const blob = new Blob([ scriptSource ], { type: 'application/javascript' });
// @ts-ignore
const blobUrl = window.URL.createObjectURL(blob);
this._worker = new Worker(blobUrl, { name: 'VB V2 Inference' });
// @ts-ignore
window.URL.revokeObjectURL(blobUrl);
const { promise, resolve, reject } = Promise.withResolvers<void>();
const handler = (e: MessageEvent) => {
if (e.data.type === 'init_done') {
this._worker?.removeEventListener('message', handler);
// Worker must report which backend it actually loaded. A missing or unknown
// value means the init_done payload is malformed and capabilities cannot be
// trusted (the rest of the pipeline keys off _capabilities.backend) — reject
// so the failure surfaces instead of silently running on the wrong backend.
const reportedBackend = e.data.backend as string;
const validBackends: string[] = Object.values(BackendType);
if (!reportedBackend || !validBackends.includes(reportedBackend)) {
reject(new Error(
'[WorkerBackend] init_done missing or invalid backend:'
+ ` ${reportedBackend ?? '<missing>'}`
));
return;
}
this._workerReady = true;
// Worker may have fallen back to TFLite if the GPU backend was unavailable.
if (reportedBackend !== this._capabilities.backend) {
logger.debug(
`[WorkerBackend] Worker fell back from ${this._capabilities.backend}`
+ ` to ${reportedBackend}`
);
this._capabilities = {
...this._capabilities,
backend: reportedBackend as BackendType,
segHeight: e.data.segHeight ?? this._capabilities.segHeight,
segWidth: e.data.segWidth ?? this._capabilities.segWidth
};
}
logger.debug(
`[WorkerBackend] Inference worker ready (tier: ${this._capabilities.tier},`
+ ` backend: ${this._capabilities.backend})`
);
resolve();
} else if (e.data.type === 'init_error') {
this._worker?.removeEventListener('message', handler);
reject(new Error(`Inference worker init error: ${e.data.error}`));
}
};
this._worker.addEventListener('message', handler);
const tfliteModelPath = `${base}libs/selfie_segmentation_landscape.tflite`;
// Without an explicit URL the body-segmentation library fetches from tfhub.dev,
// breaking offline/firewalled deployments. Deployed alongside TFLite via Makefile.
const tfjsModelUrl = `${base}libs/selfie_segmentation_landscape_tfjs/model.json`;
logger.debug(
`[WorkerBackend] Initialising inference worker — backend: ${this._capabilities.backend}`
+ ` | model: ${tfliteModelPath}`
+ ` | seg: ${this._capabilities.segWidth}x${this._capabilities.segHeight}`
);
this._worker.postMessage({
backend: this._capabilities.backend,
segHeight: this._capabilities.segHeight,
segWidth: this._capabilities.segWidth,
tfjsModelUrl,
tfliteModelPath,
tfliteWasmBase: `${base}libs/`,
type: 'init'
});
const timeout = new Promise<never>((_, rej) => {
setTimeout(() => rej(new Error('Inference worker init timed out (30s)')), 30_000);
});
return Promise.race([ promise, timeout ]);
}
/**
* Sends a pre-scaled ImageBitmap to the inference worker and awaits the mask.
*
* The bitmap is transferred (zero-copy). The worker runs segmentation and returns the raw
* mask Uint8ClampedArray (also transferred). The mask is wrapped in an ImageData and returned
* directly — no EMA smoothing is applied here.
*
* Only one inference call can be in flight at a time. The caller (processor) is responsible
* for enforcing sequential call discipline.
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at seg resolution.
* @returns {Promise<ImageData | null>} Raw mask, or null if inference failed.
*/
infer(bitmap: ImageBitmap): Promise<ImageData | null> {
if (!this._worker || !this._workerReady) {
bitmap.close();
return Promise.resolve(null);
}
const { promise, resolve } = Promise.withResolvers<ImageData | null>();
this._pendingInferResolve = resolve;
const handler = (e: MessageEvent) => {
if (e.data.type === 'mask') {
this._worker?.removeEventListener('message', handler);
if (this._pendingInferHandler === handler) {
this._pendingInferHandler = null;
}
if (this._pendingInferResolve === resolve) {
this._pendingInferResolve = null;
}
resolve(new ImageData(
e.data.data as Uint8ClampedArray,
e.data.width as number,
e.data.height as number
));
} else if (e.data.type === 'infer_error') {
this._worker?.removeEventListener('message', handler);
if (this._pendingInferHandler === handler) {
this._pendingInferHandler = null;
}
if (this._pendingInferResolve === resolve) {
this._pendingInferResolve = null;
}
logger.warn('[WorkerBackend] Worker inference error:', e.data.error);
resolve(null);
}
};
this._pendingInferHandler = handler;
this._worker.addEventListener('message', handler);
this._worker.postMessage(
{ bitmap, type: 'infer' },
[ bitmap as unknown as Transferable ]
);
return promise;
}
/**
* Stops the inference worker and releases all resources.
*
* Aborts any pending inference call (removes the message handler and resolves with null),
* sends a stop message to the worker, and terminates it.
*
* @returns {Promise<void>}
*/
async stop(): Promise<void> {
if (this._pendingInferHandler && this._worker) {
this._worker.removeEventListener('message', this._pendingInferHandler);
}
this._pendingInferHandler = null;
this._pendingInferResolve?.(null);
this._pendingInferResolve = null;
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
this._workerReady = false;
logger.debug('[WorkerBackend] Stopped');
}
}

View File

@@ -0,0 +1,123 @@
import { ICompositeOptions, ICompositor } from './ICompositor';
/**
* Canvas 2D three-pass compositor.
*
* Extracted from V1's {@code runPostProcessing} and V2's {@code _compositeFallback}.
* Uses {@code globalCompositeOperation} to blend the segmentation mask, camera
* foreground, and background into a single output frame on the CPU.
*
* Falls back gracefully when context creation fails. Callers should check
* {@link Canvas2DCompositor#isAvailable} before use.
*/
export default class Canvas2DCompositor implements ICompositor {
_canvas: HTMLCanvasElement;
_ctx: CanvasRenderingContext2D | null = null;
_maskCanvas: HTMLCanvasElement | null = null;
_maskCanvasCtx: CanvasRenderingContext2D | null = null;
/**
* Creates a Canvas2DCompositor with an internal output canvas and mask helper canvas.
*/
constructor() {
this._canvas = document.createElement('canvas');
this._ctx = this._canvas.getContext('2d');
this._maskCanvas = document.createElement('canvas');
this._maskCanvasCtx = this._maskCanvas.getContext('2d');
}
/**
* Returns true if a 2D rendering context is active and ready.
*
* @returns {boolean}
*/
get isAvailable(): boolean {
return this._ctx !== null;
}
/**
* Returns the canvas element that receives the composited output each frame.
*
* @returns {HTMLCanvasElement}
*/
get outputCanvas(): HTMLCanvasElement {
return this._canvas;
}
/**
* Composites one frame using three-pass Canvas 2D blending.
*
* 1. Draw the blurred segmentation mask (person silhouette).
* 2. Clip the camera foreground to the mask via {@code source-in}.
* 3. Draw the background behind the person via {@code destination-over}.
*
* The mask {@code ImageData} is painted onto a persistent helper canvas via
* {@code putImageData} which writes raw bytes without premultiplied-alpha
* conversion, then scaled to the output dimensions with {@code drawImage}.
*
* @param {CanvasImageSource} camera - Live camera frame.
* @param {CanvasImageSource} background - Pre-rendered background (blur or image).
* @param {ImageData} maskData - Raw segmentation mask bytes.
* @param {ICompositeOptions} options - Edge thresholds, blur radius, and first-frame flag.
* @returns {void}
*/
composite(
camera: CanvasImageSource,
background: CanvasImageSource,
maskData: ImageData,
options: ICompositeOptions): void {
const ctx = this._ctx;
const maskCanvas = this._maskCanvas;
if (!ctx || !maskCanvas) {
return;
}
const { width, height } = this._canvas;
ctx.globalCompositeOperation = 'copy';
if (maskCanvas.width !== maskData.width
|| maskCanvas.height !== maskData.height) {
maskCanvas.width = maskData.width;
maskCanvas.height = maskData.height;
}
// putImageData writes raw bytes — no premultiplied-alpha distortion.
this._maskCanvasCtx?.putImageData(maskData, 0, 0);
ctx.filter = options.cssBlurFilter;
ctx.drawImage(maskCanvas, 0, 0, width, height);
// Clip foreground to mask.
ctx.globalCompositeOperation = 'source-in';
ctx.filter = 'none';
ctx.drawImage(camera, 0, 0, width, height);
// Draw background behind the person.
ctx.globalCompositeOperation = 'destination-over';
ctx.drawImage(background as CanvasImageSource, 0, 0, width, height);
}
/**
* Releases all rendering contexts and helper canvases.
*
* @returns {void}
*/
dispose(): void {
this._ctx = null;
this._maskCanvas = null;
this._maskCanvasCtx = null;
}
/**
* Resizes the output canvas to the given dimensions.
*
* @param {number} width - New width in pixels.
* @param {number} height - New height in pixels.
* @returns {void}
*/
resize(width: number, height: number): void {
this._canvas.width = width;
this._canvas.height = height;
}
}

View File

@@ -0,0 +1,69 @@
/**
* Per-frame compositing parameters passed to every {@link ICompositor#composite} call.
*/
export interface ICompositeOptions {
/** CSS blur filter for the mask in Canvas 2D fallback (e.g. 'blur(4px)'). */
cssBlurFilter: string;
/** Smoothstep upper threshold. Pixels above this are fully opaque. */
edgeHigh: number;
/** Smoothstep lower threshold. Pixels below this are fully transparent. */
edgeLow: number;
/** Gaussian blur radius in mask-texture pixels (0 disables blur). */
maskBlurRadius: number;
}
/**
* Abstraction over the compositing backend that blends camera, background,
* and segmentation mask into a single output frame.
*
* Two implementations exist:
* - {@code WebGLCompositor} — GPU-accelerated with temporal smoothing and edge feathering.
* - {@code Canvas2DCompositor} — CPU fallback using three-pass Canvas 2D compositing.
*/
export interface ICompositor {
/**
* Composites one frame: blends camera foreground, background, and mask.
*
* @param {CanvasImageSource} camera - Live camera frame.
* @param {CanvasImageSource} background - Pre-rendered background (blur or image).
* @param {ImageData} maskData - Raw segmentation mask bytes.
* @param {ICompositeOptions} options - Edge thresholds and blur radius.
* @returns {void}
*/
composite: (
camera: CanvasImageSource,
background: CanvasImageSource,
maskData: ImageData,
options: ICompositeOptions) => void;
/**
* Releases all resources held by the compositor.
*
* @returns {void}
*/
dispose: () => void;
/**
* Whether the compositor was successfully initialised and is ready for use.
*/
readonly isAvailable: boolean;
/**
* The canvas element that receives the composited output each frame.
*/
readonly outputCanvas: HTMLCanvasElement;
/**
* Resizes the output canvas to the given dimensions.
*
* @param {number} width - New width in pixels.
* @param {number} height - New height in pixels.
* @returns {void}
*/
resize: (width: number, height: number) => void;
}

View File

@@ -0,0 +1,468 @@
import logger from '../../../virtual-background/logger';
import { ICompositeOptions, ICompositor } from './ICompositor';
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.
// Temporal smoothing (EMA) is handled CPU-side by BackgroundFrameProcessor before upload,
// so the shader only reads the current mask — no ping-pong / prev-mask sampling needed.
const VB_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_camera;
uniform sampler2D u_background;
uniform sampler2D u_mask;
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(vec2 tc) {
vec2 t = u_maskTexelSize;
return
texture2D(u_mask, tc + vec2(-t.x, -t.y)).r * 0.0625 +
texture2D(u_mask, tc + vec2( 0.0, -t.y)).r * 0.125 +
texture2D(u_mask, tc + vec2( t.x, -t.y)).r * 0.0625 +
texture2D(u_mask, tc + vec2(-t.x, 0.0)).r * 0.125 +
texture2D(u_mask, tc ).r * 0.25 +
texture2D(u_mask, tc + vec2( t.x, 0.0)).r * 0.125 +
texture2D(u_mask, tc + vec2(-t.x, t.y)).r * 0.0625 +
texture2D(u_mask, tc + vec2( 0.0, t.y)).r * 0.125 +
texture2D(u_mask, 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);
float blurred = sampleMaskBlurred(v_texCoord);
float raw = texture2D(u_mask, 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(blurred, raw, snapT);
float alpha = smoothstep(u_edgeLow, u_edgeHigh, curAlpha);
gl_FragColor = mix(bg, fg, alpha);
}
`;
// 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 implements ICompositor {
_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;
// 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;
/**
* 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();
}
/**
* Returns the canvas element that receives the composited output each frame.
*
* @returns {HTMLCanvasElement}
*/
get outputCanvas(): HTMLCanvasElement {
return this._canvas;
}
/**
* Resizes the output canvas to the given dimensions.
*
* @param {number} width - New width in pixels.
* @param {number} height - New height in pixels.
* @returns {void}
*/
resize(width: number, height: number): void {
this._canvas.width = width;
this._canvas.height = height;
}
/**
* Composites one frame via the {@link ICompositor} interface.
* Delegates to {@link compositeFromImageData}.
*
* @param {CanvasImageSource} camera - Live camera frame.
* @param {CanvasImageSource} background - Pre-rendered background.
* @param {ImageData} maskData - Raw segmentation mask.
* @param {ICompositeOptions} options - Edge thresholds and blur radius.
* @returns {void}
*/
composite(
camera: CanvasImageSource,
background: CanvasImageSource,
maskData: ImageData,
options: ICompositeOptions): void {
this.compositeFromImageData(
camera as TexImageSource,
background as HTMLCanvasElement,
maskData,
options.edgeLow,
options.edgeHigh,
options.maskBlurRadius
);
}
/**
* Composites using raw {@code ImageData} for the segmentation mask.
*
* 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 smoothing (EMA) is applied CPU-side by {@link BackgroundFrameProcessor} before
* upload, so the shader only reads the current mask — no ping-pong required.
*
* @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} edgeLow - Smoothstep lower threshold.
* @param {number} edgeHigh - Smoothstep upper threshold.
* @param {number} maskBlurRadius - Gaussian blur radius in mask-texture pixels (0 = no blur).
* Scaled from a target camera-pixel feathering width by the processor so the physical
* blur width is consistent across tiers (TFLite uses a wider target to hide upscale artefacts).
* @returns {void}
*/
compositeFromImageData(
cameraSource: TexImageSource,
backgroundSource: HTMLCanvasElement,
maskData: ImageData,
edgeLow: number,
edgeHigh: number,
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);
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);
}
/**
* 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.deleteBuffer(this._quadBuffer);
gl.deleteProgram(this._program);
this._texCamera = null;
this._texBackground = null;
this._texMask = null;
this._quadBuffer = null;
this._program = 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;
}
// 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._uEdgeLow = gl.getUniformLocation(this._program, 'u_edgeLow');
this._uEdgeHigh = gl.getUniformLocation(this._program, 'u_edgeHigh');
// Allocate textures
this._texCamera = createTexture(gl);
this._texBackground = createTexture(gl);
this._texMask = 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._quadBuffer = null;
this._texCamera = null;
this._texBackground = null;
this._texMask = 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

@@ -0,0 +1,121 @@
/* eslint-disable lines-around-comment */
import { showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { timeout } from '../../virtual-background/functions';
import logger from '../../virtual-background/logger';
import { IVirtualBackground } from '../../virtual-background/reducer';
import { detectDeviceTier } from './DeviceTierDetector';
import JitsiStreamBackgroundEffect from './JitsiStreamBackgroundEffect';
// @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'
};
let modelBuffer: ArrayBuffer;
let tflite: any;
let wasmCheck: any;
let isWasmDisabled = false;
/**
* Creates a new instance of the virtual background stream effect.
*
* @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 | undefined>}
*/
export async function createVirtualBackgroundEffect(virtualBackground: IVirtualBackground,
dispatch?: Function
): Promise<JitsiStreamBackgroundEffect | undefined> {
if (!MediaStreamTrack.prototype.getSettings && !MediaStreamTrack.prototype.getConstraints) {
throw new Error('JitsiStreamBackgroundEffect not supported!');
}
const fullVbConfig = APP.store.getState()['features/base/config'].virtualBackground;
// V2 is the default engine. V1 is only used when enableV2 is explicitly set to false.
// (The type of enableV2 is `boolean | undefined` per IConfig, so `undefined` picks V2.)
const useV2Engine = fullVbConfig?.enableV2 !== false;
if (useV2Engine) {
const vbConfig = fullVbConfig?.advanced;
const capabilities = await detectDeviceTier(vbConfig);
logger.info(
'[VirtualBackground] Using V2 engine —'
+ ` tier: ${capabilities.tier}, backend: ${capabilities.backend}`
);
const effect = new JitsiStreamBackgroundEffect(undefined, virtualBackground, {
capabilities,
enableV2: true,
vbConfig
});
await effect.init();
return effect;
}
if (isWasmDisabled) {
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
return;
}
// Checks if WebAssembly feature is supported or enabled by/in the browser.
// Conditional import of wasm-check package is done to prevent
// the browser from crashing when the user opens the app.
if (!tflite) {
try {
wasmCheck = require('wasm-check');
const tfliteTimeout = 10000;
if (wasmCheck?.feature?.simd) {
tflite = await timeout(tfliteTimeout, createTFLiteSIMDModule());
} else {
tflite = await timeout(tfliteTimeout, createTFLiteModule());
}
} catch (err: any) {
if (err?.message === '408') {
logger.error('Failed to download tflite model!');
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} else {
isWasmDisabled = true;
logger.error('Looks like WebAssembly is disabled or not supported on this browser', err);
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.webAssemblyWarning',
descriptionKey: 'virtualBackground.webAssemblyWarningDescription'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
}
return;
}
}
if (!modelBuffer) {
const modelResponse = await fetch(models.modelLandscape);
if (!modelResponse.ok) {
throw new Error('Failed to download tflite model!');
}
modelBuffer = await modelResponse.arrayBuffer();
tflite.HEAPU8.set(new Uint8Array(modelBuffer), tflite._getModelBufferMemoryOffset());
tflite._loadModel(modelBuffer.byteLength);
}
return new JitsiStreamBackgroundEffect(tflite, virtualBackground);
}

View File

@@ -0,0 +1,211 @@
import logger from '../../../virtual-background/logger';
import BackgroundFrameProcessor from '../BackgroundFrameProcessor';
/**
* Frame delivery pipeline using the Insertable Streams API.
*
* Reads {@code VideoFrame} objects from a {@code MediaStreamTrackProcessor}, passes them through
* the processor, and writes the results to a {@code MediaStreamTrackGenerator}. No
* {@code requestVideoFrameCallback} or keepalive Worker is needed — the readable stream delivers
* frames directly from the camera regardless of tab visibility.
*/
export default class InsertableStreamsPipeline {
_isLoopStarted = false;
_isRunning = false;
_outputCanvasElement: HTMLCanvasElement;
_processor: BackgroundFrameProcessor | null = null;
_trackGenerator: (MediaStreamTrack & { writable: WritableStream<VideoFrame>; }) | null = null;
_trackProcessor: { readable: ReadableStream<VideoFrame>; } | null = null;
_trackReader: ReadableStreamDefaultReader<VideoFrame> | null = null;
/**
* Creates a new insertable streams pipeline.
*/
constructor() {
// Output canvas is used as the composited-frame source for creating VideoFrames.
// Size is set in start() when the input stream dimensions are known.
this._outputCanvasElement = document.createElement('canvas');
this._outputCanvasElement.getContext('2d');
}
/**
* Returns true when the browser supports the insertable streams API.
*
* @returns {boolean}
*/
static isSupported(): boolean {
return 'MediaStreamTrackProcessor' in window
&& 'MediaStreamTrackGenerator' in window;
}
/**
* Starts the insertable streams pipeline on the given input stream.
*
* @param {MediaStream} inputStream - Camera media stream.
* @param {BackgroundFrameProcessor} processor - Per-frame processing callback.
* @returns {MediaStream} Processed output stream via MediaStreamTrackGenerator.
*/
start(inputStream: MediaStream, processor: BackgroundFrameProcessor): MediaStream {
this._isRunning = true;
this._processor = processor;
const firstVideoTrack = inputStream.getVideoTracks()[0];
const { height, width } = firstVideoTrack.getSettings
? firstVideoTrack.getSettings()
: firstVideoTrack.getConstraints();
const videoWidth = parseInt(String(width), 10);
const videoHeight = parseInt(String(height), 10);
// Size the output canvas for the Canvas 2D compositor fallback.
this._outputCanvasElement.width = videoWidth;
this._outputCanvasElement.height = videoHeight;
this._trackProcessor = new window.MediaStreamTrackProcessor(
{ track: firstVideoTrack, maxBufferSize: 2 }
);
this._trackGenerator = new window.MediaStreamTrackGenerator({ kind: 'video' });
// Patch getSettings() on the generator so JitsiLocalTrack's constructor can read
// dimensions immediately (before frames flow). MediaStreamTrackGenerator.getSettings()
// returns {} until the first frame is written; this causes the constraint-caching code
// in JitsiLocalTrack's constructor to crash. We proxy the input track's settings until
// the generator has its own, making the IS path safe to pass to the constructor directly.
// Any browser that supports IS also supports getSettings().
const inputSettings = firstVideoTrack.getSettings();
const generator = this._trackGenerator;
const originalGetSettings = generator.getSettings.bind(generator);
generator.getSettings = () => {
const settings = originalGetSettings();
// Once the generator reports its own width, stop proxying.
if (settings.width) {
return settings;
}
return { ...inputSettings, ...settings };
};
logger.debug('[InsertableStreamsPipeline] Using insertable streams path');
// Start the async frame loop. The loop processes frames immediately
// once the processor signals readiness via processFrame returning non-null.
this._runLoop();
return new MediaStream([ this._trackGenerator as MediaStreamTrack ]);
}
/**
* Stops the pipeline and releases all I/O resources.
*
* @returns {Promise<void>}
*/
async stop(): Promise<void> {
this._isRunning = false;
this._processor = null;
// Cancel the reader immediately so any VideoFrames buffered in the processor's
// queue are flushed and closed, preventing "VideoFrame GC'd without close()" warnings.
this._trackReader?.cancel().catch(() => undefined);
this._trackReader = null;
this._trackProcessor = null;
this._trackGenerator = null;
}
/**
* Drives the frame processing loop via the readable stream from the track processor.
*
* @private
* @returns {Promise<void>}
*/
async _runLoop(): Promise<void> {
if (this._isLoopStarted) {
return;
}
const processor = this._trackProcessor;
const generator = this._trackGenerator;
if (!processor || !generator) {
return;
}
this._isLoopStarted = true;
const reader = processor.readable.getReader();
// Expose the reader so stop() can cancel it immediately, causing the pending
// reader.read() to reject and the loop to exit without waiting for the next frame.
this._trackReader = reader;
const writer = generator.writable.getWriter();
try {
while (this._isRunning) {
const { value: frame, done } = await reader.read();
if (done || !frame) {
break;
}
if (!this._processor) {
try {
await writer.write(frame);
} catch (err) {
logger.error('[InsertableStreamsPipeline] Write error', err);
}
frame.close();
continue;
}
let resultCanvas: HTMLCanvasElement | null = null;
try {
resultCanvas = await this._processor.processFrame(frame);
} catch (err) {
logger.error('[InsertableStreamsPipeline] Frame processing error', err);
}
if (resultCanvas) {
// Construct the frame outside the try so the catch path doesn't have a
// partially-initialised reference, and close it in finally so the frame is
// always released even when writer.write rejects.
const outFrame = new VideoFrame(
resultCanvas, { timestamp: frame.timestamp }
);
try {
await writer.write(outFrame);
} catch (err) {
logger.error('[InsertableStreamsPipeline] Write error', err);
} finally {
outFrame.close();
}
} else {
// Passthrough — write the raw frame to the generator.
try {
await writer.write(frame);
} catch (err) {
logger.error('[InsertableStreamsPipeline] Passthrough write error', err);
}
}
frame.close();
}
} catch (err) {
// When stop() cancels the reader, reader.read() rejects — that is expected.
if (this._isRunning) {
logger.error('[InsertableStreamsPipeline] Loop error', err);
}
} finally {
this._trackReader = null;
this._isLoopStarted = false;
// Cancel reader to flush any VideoFrames still buffered in the processor's queue.
try {
await reader.cancel();
} catch { /* ignore — may have already been cancelled by stop() */ }
writer.releaseLock();
}
}
}

View File

@@ -0,0 +1,24 @@
# Virtual Background on stream effects
> From https://google.github.io/mediapipe/solutions/models.html#selfie-segmentation
#### Canvas 2D + CPU
This rendering pipeline is pretty much the same as for BodyPix. It relies on Canvas compositing properties to blend rendering layers according to the segmentation mask.
Interactions with TFLite inference tool are executed on CPU to convert from UInt8 to Float32 for the model input and to apply softmax on the model output.
The framerate is higher and the quality looks better than BodyPix
#### SIMD and non-SIMD
How to test on SIMD:
1. Go to chrome://flags/
2. Search for SIMD flag
3. Enable WebAssembly SIMD support(Enables support for the WebAssembly SIMD proposal).
4. Reopen Google Chrome
More details:
- [WebAssembly](https://webassembly.org/)
- [WebAssembly SIMD](https://github.com/WebAssembly/simd)
- [TFLite](https://blog.tensorflow.org/2020/07/accelerating-tensorflow-lite-xnnpack-integration.html)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,67 @@
/**
* SET_TIMEOUT constant is used to set interval and it is set in
* the id property of the request.data property. TimeMs property must
* also be set.
*
* ```
* //Request.data example:
* {
* id: SET_TIMEOUT,
* timeMs: 33
* }
* ```
*/
export const SET_TIMEOUT = 1;
/**
* CLEAR_TIMEOUT constant is used to clear the interval and it is set in
* the id property of the request.data property.
*
* ```
* {
* id: CLEAR_TIMEOUT
* }
* ```
*/
export const CLEAR_TIMEOUT = 2;
/**
* TIMEOUT_TICK constant is used as response and it is set in the id property.
*
* ```
* {
* id: TIMEOUT_TICK
* }
* ```
*/
export const TIMEOUT_TICK = 3;
/**
* The following code is needed as string to create a URL from a Blob.
* The URL is then passed to a WebWorker. Reason for this is to enable
* use of setInterval that is not throttled when tab is inactive.
*/
const code = `
var timer;
onmessage = function(request) {
switch (request.data.id) {
case ${SET_TIMEOUT}: {
timer = setTimeout(() => {
postMessage({ id: ${TIMEOUT_TICK} });
}, request.data.timeMs);
break;
}
case ${CLEAR_TIMEOUT}: {
if (timer) {
clearTimeout(timer);
}
break;
}
}
};
`;
// @ts-ignore
export const timerWorkerScript = URL.createObjectURL(new Blob([ code ], { type: 'application/javascript' }));

View File

@@ -0,0 +1,470 @@
/**
* Virtual Background V2 — inference worker.
*
* Handles TF.js body-segmentation (MEDIUM/HIGH tiers via WebGL/WebGPU) and TFLite WASM
* (LOW tier via selfie_segmentation_landscape) 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, segWidth: number, segHeight: number,
* tfjsModelUrl: string, tfliteModelPath: string, tfliteWasmBase: string }
* { type: 'infer', bitmap: ImageBitmap } (bitmap is transferred -- zero-copy)
*
* 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';
// @ts-ignore
import createTFLiteSIMDModule from '../vendor/tflite/tflite-simd';
/** 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);
}
};
/**
* 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 {number} data.segHeight - Segmentation canvas height.
* @param {number} data.segWidth - Segmentation canvas width.
* @param {string} data.tfjsModelUrl - Absolute URL of the self-hosted TF.js graph model (model.json).
* @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;
segHeight: number;
segWidth: number;
tfjsModelUrl: string;
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.
*
* The segmentation model is loaded from the deployment's own libs/ directory. Passing an
* explicit modelUrl is required: without one, the body-segmentation library fetches the model
* from tfhub.dev, which fails in offline/firewalled deployments — and when the network hangs
* instead of erroring, the fetch outlives the main thread's init timeout so the TFLite
* fallback below never gets a chance to run.
*
* 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 {number} data.segWidth - Unused here; inference at bitmap resolution.
* @param {number} data.segHeight - Unused here; inference at bitmap resolution.
* @param {string} data.tfjsModelUrl - Absolute URL of the self-hosted TF.js graph model (model.json).
* @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;
segHeight: number;
segWidth: number;
tfjsModelUrl: string;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
const { backend, tfjsModelUrl, tfliteModelPath, tfliteWasmBase } = data;
if (!isSameOriginUrl(tfjsModelUrl)) {
workerPost({ error: 'TF.js model URL must be same-origin', type: 'init_error' });
return;
}
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: 144, segWidth: 256, tfliteModelPath, tfliteWasmBase });
return;
}
segmenter = await bs.createSegmenter(
bs.SupportedModels.MediaPipeSelfieSegmentation,
{
modelType: 'landscape',
modelUrl: tfjsModelUrl,
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: 144, segWidth: 256, tfliteModelPath, tfliteWasmBase });
} catch (tfliteErr) {
workerPost({ error: String(tfliteErr), type: 'init_error' });
}
}
}
/**
* Initialises the TFLite WASM backend for LOW tier using the selfie_segmentation_landscape model.
*
* Loads the SIMD WASM runtime via createTFLiteSIMDModule 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;
}
// 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 createTFLiteSIMDModule({
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;
// Bounds-check the HEAPF32 input/output regions against the requested seg dimensions.
// Catches a mismatch between the configured seg size and the model's actual tensor
// shape before the inference loop starts silently clobbering WASM heap memory.
const heapLen = tfliteModule.HEAPF32.length;
const inputEnd = tfliteInputOffset + tflitePixelCount * 3; // NHWC float32, 3 channels.
const outputEnd = tfliteOutputOffset + tflitePixelCount; // Single float per pixel.
if (inputEnd > heapLen || outputEnd > heapLen) {
workerPost({
error: `TFLite heap bounds mismatch: segWidth=${segWidth} segHeight=${segHeight}`
+ ` requires inputEnd=${inputEnd}/outputEnd=${outputEnd} but HEAPF32 is ${heapLen}.`
+ ' Model input shape likely differs from the configured seg resolution.',
type: 'init_error'
});
return;
}
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 (the model 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]).
// Model 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();
}
}