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

400 lines
15 KiB
TypeScript

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;
}
}