Attach background effects through one switchable pipeline

- One pipeline, shared by the pre-join preview and the call, attached the
  first time an effect is wanted and never detached after; turning blur
  off switches it to a disabled mode in place. Main dropped the processor
  whenever blur went off, and a pipeline taken off the camera is destroyed
  and re-primed, so each time blur came back one unprocessed frame went
  out. Built once, the priming frame is spent once.
- Still driven by the existing blur setting: nothing a user sees changes.
- Its logic is BackgroundEffects in src/state/, which the provider only
  builds, so the tests drive it without rendering anything.
- Disabled drops the blur radius too. The SDK's disabled mode keeps it,
  and with a radius every frame is still segmented and thrown away; with
  neither a radius nor a picture, frames pass untouched.
- Switches run one at a time and skip any overtaken, so an earlier,
  slower switch can't land after a later choice.
- Builds, rebuilds and teardowns of the pipeline run one at a time
  (OneStepPipeline). The preview's and the call's tracks attach and stop
  it independently, and the SDK's wrapper holds one track's streams: two
  builds at once overwrite each other's, and a stop during a build does
  nothing.
- Every camera track starts with the pipeline on: the room's capture
  defaults follow it, and a camera turned off before the first effect is
  replaced on turning it on, since a stopped track can't take one. One
  attached after publishing lets the room through while it is built.
- BlurBackgroundTransformer becomes BackgroundEffectTransformer: it also
  sits disabled, and its init applies that state as the SDK's would.

Rebased onto main, where src/livekit moved to src/tracks and publishing
moved into the SDK:
- Publisher.ts is gone. Starting each camera track with the pipeline is now
  the processor on the camera publish request, at the join and on turning
  the camera on (CallViewModel). Replacing a camera turned off before the
  first effect is done in the camera's mute handler, which unpublishes and
  publishes it anew; its tests moved to CallViewModel.test.ts. The SDK's
  publisher, starting the microphone and camera in one call, takes the
  requests' options into the room's capture defaults, since a camera turned
  on before the start would otherwise come up without the processor.
- The SDK gives one VideoProcessor one LiveKit adapter, and setProcessor
  leaves a track alone that already runs it, so a track made with the
  pipeline isn't torn down when the call sets it again.
- BackgroundEffects wraps the pipeline once with fromLivekitProcessor, so the
  call and preview see one VideoProcessor throughout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-11-03 16:05:25 +01:00
committed by Timo K.
co-authored by Claude Opus 5.5
parent 2fad4e08b1
commit a91dab8f3e
16 changed files with 769 additions and 59 deletions
@@ -230,9 +230,10 @@ export function createLocalLivekitMediaTrack(
const track = videoTrack();
// A processor cannot be built on a track that has already ended
if (!track || track.mediaStreamTrack.readyState === "ended") return;
if (processor)
await track.setProcessor(convertToLivekitProcessor(processor));
else if (track.getProcessor()) await track.stopProcessor();
if (processor) {
const adapter = convertToLivekitProcessor(processor);
if (track.getProcessor() !== adapter) await track.setProcessor(adapter);
} else if (track.getProcessor()) await track.stopProcessor();
},
};
}
@@ -18,9 +18,10 @@ import {
import { logger } from "matrix-js-sdk/lib/logger";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type PublishRequest } from "../../media-api";
import { type PublishRequest, type VideoProcessor } from "../../media-api";
import { flushPromises } from "../../utils/test";
import { type DesiredMedia, Publisher } from "./Publisher";
import { convertToLivekitProcessor } from "./videoProcessor";
describe("Publisher", () => {
describe("setAudioOutputDevice", () => {
@@ -119,6 +120,24 @@ describe("Publisher", () => {
});
});
it("creates both with the options of requests made since the join", async () => {
const processor: VideoProcessor = {
name: "effect",
init: vi.fn(),
restart: vi.fn(),
destroy: vi.fn(),
};
want(microphone, { source: "camera", deviceId: "front", processor });
publisher.start();
await flushPromises();
expect(room.options.videoCaptureDefaults).toMatchObject({
deviceId: "front",
processor: convertToLivekitProcessor(processor),
});
});
it.each([
{ enabled: microphone, muted: camera, options: { audio: true } },
{ enabled: camera, muted: microphone, options: { video: true } },
+24 -1
View File
@@ -87,8 +87,10 @@ export class Publisher {
// permission once; the room defaults carry their options. LiveKit
// resolves these once the track is published, which may block on the
// connection; LocalTrackPublished is what tells us a track exists.
if (sources.has("microphone") && sources.has("camera"))
if (sources.has("microphone") && sources.has("camera")) {
this.takeCaptureDefaults(requests);
void this.room.localParticipant.enableCameraAndMicrophone();
}
for (const request of requests)
if (
request.source === "screenShare" ||
@@ -99,6 +101,27 @@ export class Publisher {
});
}
/**
* Makes the requests the room's capture defaults. The room took its defaults
* from the requests at the join, but one made since, such as a camera turned
* on before the start, has options of its own: a video processor dropped
* here would let the room see the camera before it is applied.
*/
private takeCaptureDefaults(requests: PublishRequest[]): void {
const options = this.room.options;
for (const request of requests)
if (request.source === "microphone")
options.audioCaptureDefaults = {
...options.audioCaptureDefaults,
...audioCaptureOptions(request),
};
else if (request.source === "camera")
options.videoCaptureDefaults = {
...options.videoCaptureDefaults,
...videoCaptureOptions(request),
};
}
/** Publishes one request and resolves with its publication once LiveKit has it. */
public async publish(
request: PublishRequest,
@@ -47,4 +47,11 @@ describe("convertToLivekitProcessor", () => {
await adapter.destroy();
expect(processor.destroy).toHaveBeenCalled();
});
it("gives one processor one adapter", () => {
const processor = fakeProcessor();
expect(convertToLivekitProcessor(processor)).toBe(
convertToLivekitProcessor(processor),
);
});
});
@@ -9,14 +9,24 @@ import { type Track, type TrackProcessor } from "livekit-client";
import { type VideoProcessor } from "../../media-api";
const adapters = new WeakMap<
VideoProcessor,
TrackProcessor<Track.Kind.Video>
>();
/**
* A `VideoProcessor` as livekit-client wants it. LiveKit reads the processed
* track off the processor after `init` and `restart` rather than taking it as
* their result, so the adapter keeps it there.
*
* One adapter per processor, so a track can tell that it already runs one: a
* processor set again would otherwise be torn down and built anew.
*/
export function convertToLivekitProcessor(
processor: VideoProcessor,
): TrackProcessor<Track.Kind.Video> {
const existing = adapters.get(processor);
if (existing) return existing;
const adapter: TrackProcessor<Track.Kind.Video> = {
name: processor.name,
init: async (opts) => {
@@ -30,5 +40,6 @@ export function convertToLivekitProcessor(
},
destroy: async () => processor.destroy(),
};
adapters.set(processor, adapter);
return adapter;
}
+6 -1
View File
@@ -170,7 +170,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
);
const rtcParticipation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
publish: initialPublishRequests(
props.muteStates,
mediaDevices,
capture,
trackProcessorState$.value.processor,
),
});
const vm = createCallViewModel$(
+131
View File
@@ -0,0 +1,131 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, distinctUntilChanged, map } from "rxjs";
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
import {
BackgroundEffects,
type BackgroundEffectsOptions,
} from "./BackgroundEffects";
import { type ProcessorState } from "../tracks/TrackProcessorContext";
import { flushPromises, testScope, withTestScheduler } from "../utils/test";
/** A pipeline that records what it is switched to. */
function fakePipeline(): {
pipeline: BackgroundProcessorWrapper;
switches: unknown[];
/** Holds the next switch until it resolves, as a picture loading does. */
holdNext: () => () => void;
} {
const switches: unknown[] = [];
let slow: Promise<void> | undefined;
const pipeline = {
switchTo: vi.fn(async (options: unknown) => {
switches.push(options);
const held = slow;
slow = undefined;
await held;
}),
} as unknown as BackgroundProcessorWrapper;
return {
pipeline,
switches,
holdNext: () => {
let release!: () => void;
slow = new Promise((resolve) => (release = resolve));
return release;
},
};
}
/** One letter per state: idle, attached. */
function letter(state: ProcessorState): string {
return state.processor === undefined ? "i" : "a";
}
describe("the pipeline's state", () => {
function testState({
effect,
expected,
}: {
effect: string;
expected: string;
}): void {
withTestScheduler(({ behavior, expectObservable }) => {
const effects = new BackgroundEffects(testScope(), {
supported: true,
blur$: behavior(effect, { n: false, b: true }),
pipeline: fakePipeline().pipeline,
});
expectObservable(
effects.state$.pipe(map(letter), distinctUntilChanged()),
).toBe(expected);
});
}
it("attaches on first use and stays attached", () =>
testState({ effect: "nbn", expected: "ia" }));
});
describe("background effects", () => {
let blur$: BehaviorSubject<boolean>;
let fake: ReturnType<typeof fakePipeline>;
function build(
options: Partial<BackgroundEffectsOptions> = {},
): BackgroundEffects {
return new BackgroundEffects(testScope(), {
supported: true,
blur$,
pipeline: fake.pipeline,
...options,
});
}
const blur = async (on: boolean): Promise<void> => {
blur$.next(on);
await flushPromises();
};
beforeEach(() => {
blur$ = new BehaviorSubject(false);
fake = fakePipeline();
});
it("switches in place rather than reattaching", async () => {
const effects = build();
await blur(true);
const pipeline = effects.state$.value.processor;
await blur(false);
await blur(true);
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual([
{ mode: "background-blur", blurRadius: 15 },
{ mode: "disabled" },
{ mode: "background-blur", blurRadius: 15 },
]);
});
it("switches one at a time, skipping those overtaken", async () => {
const finished = fake.holdNext();
build();
await blur(true);
await blur(false);
await blur(true);
await blur(false);
expect(fake.switches).toHaveLength(1);
finished();
await flushPromises();
expect(fake.switches).toEqual([
{ mode: "background-blur", blurRadius: 15 },
{ mode: "disabled" },
]);
});
});
+98
View File
@@ -0,0 +1,98 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, distinctUntilChanged, filter, map, scan } from "rxjs";
import {
type BackgroundProcessorWrapper,
type SwitchBackgroundProcessorOptions,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
import { type ObservableScope } from "./ObservableScope";
import {
fromLivekitProcessor,
type ProcessorState,
} from "../tracks/TrackProcessorContext";
const blurRadius = 15;
export interface BackgroundEffectsOptions {
/** Whether this browser can run a pipeline at all. */
supported: boolean;
/** Whether blur is chosen. */
blur$: Behavior<boolean>;
/**
* Shared by the pre-join preview and the call. Building or destroying it is
* what primes it, and a primed pipeline lets its next frame through
* untouched, so it is switched rather than rebuilt.
*/
pipeline: BackgroundProcessorWrapper;
}
/** The background effect pipeline, as the camera tracks and the menus see it. */
export class BackgroundEffects {
public readonly state$: Behavior<ProcessorState>;
public constructor(
scope: ObservableScope,
{ supported, blur$, pipeline }: BackgroundEffectsOptions,
) {
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
this.state$ = scope.behavior(
blur$.pipe(
scan<boolean, ProcessorState>(
(previous, wanted) => {
// Attached the first time an effect is wanted and never detached
// after, so someone who never turns one on pays for none of it.
const attached =
previous.processor !== undefined || (supported && wanted);
return { supported, processor: attached ? processor : undefined };
},
{ supported, processor: undefined },
),
),
);
const switchTo = oneSwitchAtATime(pipeline);
combineLatest([this.state$, blur$])
.pipe(
filter(([{ processor }]) => processor !== undefined),
map(([, blur]): SwitchBackgroundProcessorOptions =>
blur ? { mode: "background-blur", blurRadius } : { mode: "disabled" },
),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
scope.bind(),
)
.subscribe((options) => {
switchTo(options).catch((e) =>
logger.warn("Failed to switch background effect", e),
);
});
}
}
/**
* Switches the pipeline one choice at a time, skipping those overtaken while
* they waited. A switch to a picture ends only once it has loaded, so a
* slower, earlier choice would otherwise land after a later one.
*/
function oneSwitchAtATime(
pipeline: BackgroundProcessorWrapper,
): (options: SwitchBackgroundProcessorOptions) => Promise<void> {
let latest: SwitchBackgroundProcessorOptions | undefined;
let queue = Promise.resolve();
return async (options) => {
latest = options;
const turn = queue.then(async () => {
if (options === latest) await pipeline.switchTo(options);
});
queue = turn.catch(() => {});
return turn;
};
}
+81 -1
View File
@@ -23,6 +23,10 @@ import {
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
type RTCParticipation,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { type Layout } from "../layout-types.ts";
import {
@@ -31,7 +35,9 @@ import {
mockRemoteParticipant,
withTestScheduler,
mockRtcMembership,
mockMuteStates,
testScope,
flushPromises,
} from "../../utils/test.ts";
import { E2eeType } from "../../e2ee/e2eeType.ts";
import {
@@ -62,7 +68,11 @@ import {
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
import { initializeWidget } from "../../widget.ts";
import { computeUrlParams } from "../../UrlParams.ts";
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
import {
callViewModelOptionsFromParams,
initialPublishRequests,
} from "./CallViewModel.ts";
import { type Handler, MuteState } from "../MuteStates.ts";
initializeWidget();
@@ -1664,3 +1674,73 @@ describe("callViewModelOptionsFromParams", () => {
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
});
});
describe("a camera with a video processor", () => {
const withCallViewModel = withCallViewModelInMode(MatrixRTCMode.Matrix_2_0);
const processor = { name: "effect" } as VideoProcessor;
/** The handler the view model gives the camera's mute switch. */
const cameraHandler = (setHandler: {
mock: { calls: [Handler][] };
}): Handler => setHandler.mock.calls[1][0];
it("starts each camera track with the processor", () => {
const muteStates = mockMuteStates();
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
expect(
initialPublishRequests(
muteStates,
mockMediaDevices({}),
{},
processor,
).find((request) => request.source === "camera"),
).toMatchObject({ processor });
});
it("replaces a camera turned off before the processor was chosen", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
undefined,
);
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ videoProcessor$ },
);
// The camera is off, so it is stopped and can't take the processor
videoProcessor$.next(processor);
await cameraHandler(setHandler)(true);
await flushPromises();
expect(participation!.unpublish).toHaveBeenCalledWith("camera");
expect(participation!.publish).toHaveBeenCalledWith(
expect.objectContaining({ source: "camera", processor }),
);
});
it("turns on a camera that took the processor while running", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
undefined,
);
let participation: RTCParticipation | undefined;
withCallViewModel(
{ videoEnabled: new Map([[localParticipant, constant(true)]]) },
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ videoProcessor$ },
);
videoProcessor$.next(processor);
await cameraHandler(setHandler)(true);
await flushPromises();
expect(participation!.unpublish).not.toHaveBeenCalled();
expect(participation!.publish).not.toHaveBeenCalled();
});
});
+36 -9
View File
@@ -135,14 +135,16 @@ export interface CaptureSettings {
}
/**
* What to publish for a microphone or camera: the device the user picked and
* the host's capture settings. The video processor is not in here; the call
* attaches it to the camera track once that exists.
* What to publish for a microphone or camera: the device the user picked, the
* host's capture settings and, for the camera, the video processor, so that
* the track starts with it rather than letting the room through while it is
* built.
*/
export function publishRequest(
source: "microphone" | "camera",
mediaDevices: MediaDevices,
capture: CaptureSettings,
processor?: VideoProcessor,
): PublishRequest {
return source === "microphone"
? {
@@ -154,6 +156,7 @@ export function publishRequest(
source,
deviceId: mediaDevices.videoInput.selected$.value?.id,
capture: capture.camera,
...(processor && { processor }),
};
}
@@ -162,12 +165,13 @@ export function initialPublishRequests(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
processor?: VideoProcessor,
): PublishRequest[] {
const requests: PublishRequest[] = [];
if (muteStates.audio.enabled$.value)
requests.push(publishRequest("microphone", mediaDevices, capture));
if (muteStates.video.enabled$.value)
requests.push(publishRequest("camera", mediaDevices, capture));
requests.push(publishRequest("camera", mediaDevices, capture, processor));
return requests;
}
@@ -503,6 +507,17 @@ export function createCallViewModel$(
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
// Camera tracks the processor reached while they ran. A camera turned off is
// stopped, and a stopped track can't take one.
const tookProcessor = new WeakMap<LocalVideoMediaTrack, VideoProcessor>();
const missesProcessor = (track: LocalMediaTrack): boolean => {
const processor = videoProcessor$.value;
return (
processor !== undefined &&
tookProcessor.get(track as LocalVideoMediaTrack) !== processor
);
};
// The mute switches drive our tracks, which report back what the device
// allowed, so a denied permission flips the switch back. A source is muted
// and unmuted while its track exists and published when it does not.
@@ -520,14 +535,23 @@ export function createCallViewModel$(
muteState.setHandler(async (enabled) => {
desired = enabled;
const track = track$.value;
if (track !== undefined) {
// A camera turned off before the processor was chosen is replaced, so
// that the new one starts with it.
if (enabled && source === "camera" && track && missesProcessor(track))
await rtcParticipation.unpublish("camera");
else if (track !== undefined) {
desired = await track.setEnabled(enabled);
return desired;
}
if (!enabled) return false;
try {
await rtcParticipation.publish(
publishRequest(source, mediaDevices, capture),
publishRequest(
source,
mediaDevices,
capture,
source === "camera" ? videoProcessor$.value : undefined,
),
);
return true;
} catch (e) {
@@ -583,12 +607,15 @@ export function createCallViewModel$(
});
});
// Attached once the camera track exists rather than carried in the publish
// request, so that a new track and a change of processor take the same path
// Also set on each camera track as it appears and as the choice changes. A
// track made with the processor already runs it and is left alone.
combineLatest([camera$, videoProcessor$])
.pipe(scope.bind())
.subscribe(([camera, processor]) => {
camera?.setProcessor(processor).catch((e) => {
if (camera === undefined) return;
if (processor && !camera.muted$.value)
tookProcessor.set(camera, processor);
camera.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
});
});
@@ -0,0 +1,24 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
describe("BackgroundEffectTransformer", () => {
it("passes frames on untouched once disabled, even after blurring", async () => {
const transformer = new BackgroundEffectTransformer({});
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
await transformer.update({
imagePath: undefined,
backgroundDisabled: true,
});
// With neither, the library passes a frame on without segmenting it.
expect(transformer.options.blurRadius).toBeUndefined();
expect(transformer.options.imagePath).toBeUndefined();
});
});
@@ -1,5 +1,6 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
@@ -9,6 +10,7 @@ import {
BackgroundTransformer,
VideoTransformer,
type VideoTransformerInitOptions,
type BackgroundOptions,
} from "@livekit/track-processors";
import { ImageSegmenter } from "@mediapipe/tasks-vision";
@@ -49,7 +51,18 @@ const wasmFileset: WasmFileset = {
* loads the segmentation models from our own bundle rather than as an external
* resource fetched from the public internet.
*/
export class BlurBackgroundTransformer extends BackgroundTransformer {
export class BackgroundEffectTransformer extends BackgroundTransformer {
/**
* As the library's, except that disabling also drops the blur radius: kept,
* it has every frame segmented and the result thrown away, where with
* neither a radius nor a picture frames are passed on untouched.
*/
public override async update(opts: BackgroundOptions): Promise<void> {
await super.update(
opts.backgroundDisabled ? { ...opts, blurRadius: undefined } : opts,
);
}
public async init({
outputCanvas,
inputElement: inputVideo,
@@ -73,8 +86,10 @@ export class BlurBackgroundTransformer extends BackgroundTransformer {
outputConfidenceMasks: false,
});
// BackgroundTransformer's own init applies these, and this one replaces it.
if (this.options.blurRadius) {
this.gl?.setBlurRadius(this.options.blurRadius);
}
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProcessorWrapper } from "@livekit/track-processors";
import { OneStepPipeline } from "./OneStepPipeline";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import { flushPromises } from "../utils/test";
const opts = {} as Parameters<OneStepPipeline["init"]>[0];
describe("OneStepPipeline", () => {
afterEach(() => vi.restoreAllMocks());
it("stops and builds again only once a build has finished", async () => {
let finishBuild!: () => void;
const init = vi
.spyOn(ProcessorWrapper.prototype, "init")
.mockImplementationOnce(
async () => new Promise((resolve) => (finishBuild = resolve)),
)
.mockResolvedValue();
const destroy = vi
.spyOn(ProcessorWrapper.prototype, "destroy")
.mockResolvedValue();
const pipeline = new OneStepPipeline(
new BackgroundEffectTransformer({}),
"test",
);
const first = pipeline.init(opts);
const stopped = pipeline.destroy();
const second = pipeline.init(opts);
await flushPromises();
expect(init).toHaveBeenCalledTimes(1);
expect(destroy).not.toHaveBeenCalled();
finishBuild();
await Promise.all([first, stopped, second]);
expect(init).toHaveBeenCalledTimes(2);
expect(destroy.mock.invocationCallOrder[0]).toBeLessThan(
init.mock.invocationCallOrder[1],
);
});
it("restarts in one step, without waiting on itself", async () => {
const init = vi
.spyOn(ProcessorWrapper.prototype, "init")
.mockResolvedValue();
const destroy = vi
.spyOn(ProcessorWrapper.prototype, "destroy")
.mockResolvedValue();
const pipeline = new OneStepPipeline(
new BackgroundEffectTransformer({}),
"test",
);
await pipeline.restart(opts);
expect(destroy).toHaveBeenCalledWith({ willProcessorRestart: true });
expect(init).toHaveBeenCalledOnce();
});
it("carries on after a step that failed", async () => {
vi.spyOn(ProcessorWrapper.prototype, "init")
.mockRejectedValueOnce(new Error("no GPU"))
.mockResolvedValue();
const pipeline = new OneStepPipeline(
new BackgroundEffectTransformer({}),
"test",
);
await expect(pipeline.init(opts)).rejects.toThrow("no GPU");
await expect(pipeline.init(opts)).resolves.toBeUndefined();
});
});
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BackgroundProcessorWrapper } from "@livekit/track-processors";
type InitOptions = Parameters<BackgroundProcessorWrapper["init"]>[0];
type DestroyOptions = Parameters<BackgroundProcessorWrapper["destroy"]>[0];
/**
* A background pipeline that is built, rebuilt and destroyed one step at a
* time. The camera tracks sharing it attach and stop it independently, and
* the SDK's wrapper holds a single track's streams: two builds at once
* overwrite each other's, and a destroy during a build does nothing.
*/
export class OneStepPipeline extends BackgroundProcessorWrapper {
private steps: Promise<void> = Promise.resolve();
public override async init(opts: InitOptions): Promise<void> {
return this.inTurn(async () => super.init(opts));
}
// The SDK's restart calls init and destroy, which would wait on itself.
public override async restart(opts: InitOptions): Promise<void> {
return this.inTurn(async () => {
await super.destroy({ willProcessorRestart: true });
await super.init(opts);
});
}
public override async destroy(options?: DestroyOptions): Promise<void> {
return this.inTurn(async () => super.destroy(options));
}
private async inTurn(step: () => Promise<void>): Promise<void> {
const turn = this.steps.then(step);
this.steps = turn.catch(() => {});
return turn;
}
}
+147 -2
View File
@@ -5,22 +5,59 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { act, createElement, type FC } from "react";
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type LocalVideoTrack } from "livekit-client";
import {
type BackgroundOptions,
type ProcessorWrapper,
} from "@livekit/track-processors";
import { type VideoProcessor } from "@element-hq/matrixrtc-sdk";
import {
convertToLivekitProcessor,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import {
applyProcessor,
fromLivekitProcessor,
ProcessorProvider,
type ProcessorState,
trackProcessorSync,
useTrackProcessor,
useTrackProcessorSync,
} from "./TrackProcessorContext";
import { backgroundBlur } from "../settings/settings";
import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test";
const pipelines = vi.hoisted(() => ({
built: 0,
destroyed: 0,
switches: [] as unknown[],
}));
// The pipeline itself needs WebGL and MediaPipe; what these tests are about is
// what the provider asks of it.
vi.mock("@livekit/track-processors", () => ({
BackgroundProcessorWrapper: vi.fn(function (this: Record<string, unknown>) {
pipelines.built += 1;
this.switchTo = vi.fn(async (options: unknown) => {
pipelines.switches.push(options);
return Promise.resolve();
});
this.destroy = vi.fn(async () => {
pipelines.destroyed += 1;
return Promise.resolve();
});
}),
supportsBackgroundProcessors: (): boolean => true,
}));
vi.mock("./BackgroundEffectTransformer", () => ({
BackgroundEffectTransformer: vi.fn(),
}));
vi.mock("../Platform", () => ({ platform: "desktop" }));
const processor = { name: "blur" } as VideoProcessor;
function mockTrack(
@@ -131,3 +168,111 @@ describe("fromLivekitProcessor", () => {
).rejects.toThrow("produced no track");
});
});
describe("ProcessorProvider", () => {
/** A camera track that holds whatever processor it is given. */
function cameraTrack(): LocalVideoTrack {
let current: unknown;
return {
mediaStreamTrack: { readyState: "live" },
getProcessor: vi.fn(() => current),
setProcessor: vi.fn(async (next: unknown) => {
current = next;
return Promise.resolve();
}),
stopProcessor: vi.fn(async () => {
current = undefined;
return Promise.resolve();
}),
} as unknown as LocalVideoTrack;
}
let seen: ProcessorState[];
const Surface: FC<{ track: LocalVideoTrack | null }> = ({ track }) => {
seen.push(useTrackProcessor());
useTrackProcessorSync(track);
return null;
};
// One component for every render, so a rerender updates the tree rather than
// mounting a second provider with a pipeline of its own.
const Surfaces: FC<{ tracks: (LocalVideoTrack | null)[] }> = ({ tracks }) =>
createElement(
ProcessorProvider,
null,
createElement(
"div",
null,
...tracks.map((track, key) => createElement(Surface, { key, track })),
),
);
const surfaces = (
...tracks: (LocalVideoTrack | null)[]
): ReturnType<typeof createElement> => createElement(Surfaces, { tracks });
const blur = async (on: boolean): Promise<void> => {
await act(async () => {
backgroundBlur.setValue(on);
await flushPromises();
});
};
const latest = (): ProcessorState => seen[seen.length - 1];
beforeEach(() => {
seen = [];
pipelines.built = 0;
pipelines.destroyed = 0;
pipelines.switches = [];
backgroundBlur.setValue(false);
});
afterEach(() => backgroundBlur.setValue(false));
// A pipeline primes itself when it is built and when it is destroyed, and a
// primed pipeline lets one frame through untouched: so the frame is spent
// once, as long as it is built once and never taken off the camera.
it("spends one priming frame on first use and none afterwards", async () => {
const track = cameraTrack();
render(surfaces(track));
for (const on of [true, false, true, false, true]) await blur(on);
expect(pipelines.built).toBe(1);
expect(pipelines.destroyed).toBe(0);
expect(track.setProcessor).toHaveBeenCalledTimes(1);
expect(track.stopProcessor).not.toHaveBeenCalled();
});
it("preview and call share one pipeline", async () => {
const preview = cameraTrack();
const call = cameraTrack();
render(surfaces(preview, call));
await blur(true);
const pipeline = latest().processor;
expect(preview.setProcessor).toHaveBeenCalledWith(
convertToLivekitProcessor(pipeline!),
);
expect(call.setProcessor).toHaveBeenCalledWith(
convertToLivekitProcessor(pipeline!),
);
expect(pipelines.built).toBe(1);
});
it("applies an effect chosen while the camera is off", async () => {
const view = render(surfaces(null));
await blur(true);
// Already switched to blur, with nothing yet to attach it to.
expect(pipelines.switches).toEqual([
{ mode: "background-blur", blurRadius: 15 },
]);
const track = cameraTrack();
await act(async () => {
view.rerender(surfaces(track));
await flushPromises();
});
expect(track.setProcessor).toHaveBeenCalledTimes(1);
expect(track.setProcessor).toHaveBeenCalledWith(
convertToLivekitProcessor(latest().processor!),
);
expect(pipelines.built).toBe(1);
expect(pipelines.switches).toHaveLength(1);
});
});
+41 -40
View File
@@ -1,12 +1,13 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ProcessorWrapper,
type ProcessorWrapper,
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
type BackgroundOptions,
} from "@livekit/track-processors";
@@ -16,7 +17,7 @@ import {
type JSX,
use,
useEffect,
useMemo,
useState,
} from "react";
import { type LocalVideoTrack, Track } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
@@ -26,15 +27,14 @@ import {
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import {
backgroundBlur as backgroundBlurSettings,
useSetting,
} from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { backgroundBlur as backgroundBlurSettings } from "../settings/settings";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import { OneStepPipeline } from "./OneStepPipeline";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { ObservableScope } from "../state/ObservableScope";
import { BackgroundEffects } from "../state/BackgroundEffects";
import { useBehavior } from "../useBehavior";
import { platform } from "../Platform";
import { useValueBehavior } from "../useValueBehavior";
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
@@ -72,19 +72,21 @@ export function fromLivekitProcessor(
};
}
const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
const ProcessorContext = createContext<BackgroundEffects | undefined>(
undefined,
);
export function useTrackProcessor(): ProcessorState {
const state = use(ProcessorContext);
if (state === undefined)
throw new Error(
"useTrackProcessor must be used within a ProcessorProvider",
);
return state;
return useBehavior(useTrackProcessorState$());
}
export function useTrackProcessorState$(): Behavior<ProcessorState> {
return useValueBehavior(useTrackProcessor());
const effects = use(ProcessorContext);
if (effects === undefined)
throw new Error(
"useTrackProcessor must be used within a ProcessorProvider",
);
return effects.state$;
}
/**
@@ -104,8 +106,13 @@ export function applyProcessor(
logger.debug("Not attaching video processor to an ended track");
return;
}
const track = videoTrack.mediaStreamTrack;
videoTrack.setProcessor(convertToLivekitProcessor(processor)).catch((e) => {
logger.warn("Failed to attach video processor", e);
// The pipeline builds one track at a time, so a track can end while its
// build waits a turn. The camera's next track attaches it again.
if (track.readyState === "ended")
logger.debug("Video processor not attached: the track ended first");
else logger.warn("Failed to attach video processor", e);
});
}
if (!processor && videoTrack.getProcessor()) {
@@ -151,28 +158,22 @@ function supportsBackgroundProcessors(): boolean {
}
export const ProcessorProvider: FC<Props> = ({ children }) => {
// The setting the user wants to have
const [blurActivated] = useSetting(backgroundBlurSettings);
const supported = useMemo(() => supportsBackgroundProcessors(), []);
const blur = useMemo(
() =>
fromLivekitProcessor(
new ProcessorWrapper(
new BlurBackgroundTransformer({ blurRadius: 15 }),
"background-blur",
const [effects, setEffects] = useState<BackgroundEffects | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setEffects(
new BackgroundEffects(scope, {
supported: supportsBackgroundProcessors(),
blur$: backgroundBlurSettings.value$,
pipeline: new OneStepPipeline(
new BackgroundEffectTransformer({ backgroundDisabled: true }),
"background-effect",
),
),
[],
);
}),
);
return (): void => scope.end();
}, []);
// This is the actual state exposed through the context
const processorState = useMemo(
() => ({
supported,
processor: supported && blurActivated ? blur : undefined,
}),
[supported, blurActivated, blur],
);
return <ProcessorContext value={processorState}>{children}</ProcessorContext>;
if (effects === null) return null;
return <ProcessorContext value={effects}>{children}</ProcessorContext>;
};