Merge pull request #4231 from element-hq/matthew/blur-processor-ended-track

Don't attach the background blur processor to an ended camera track
This commit is contained in:
Robin
2026-09-10 21:27:11 +02:00
committed by GitHub
2 changed files with 118 additions and 13 deletions
+87
View File
@@ -0,0 +1,87 @@
/*
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, vi } from "vitest";
import { type LocalVideoTrack } from "livekit-client";
import {
type BackgroundOptions,
type ProcessorWrapper,
} from "@livekit/track-processors";
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test";
const processor = {} as ProcessorWrapper<BackgroundOptions>;
function mockTrack(
readyState: MediaStreamTrackState,
current?: ProcessorWrapper<BackgroundOptions>,
): LocalVideoTrack {
return {
mediaStreamTrack: { readyState },
getProcessor: vi.fn().mockReturnValue(current),
setProcessor: vi.fn().mockResolvedValue(undefined),
stopProcessor: vi.fn().mockResolvedValue(undefined),
} as unknown as LocalVideoTrack;
}
describe("applyProcessor", () => {
it("attaches the processor to a live track", () => {
const track = mockTrack("live");
applyProcessor(track, processor);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
it("does not attach the processor to an ended track", () => {
const track = mockTrack("ended");
applyProcessor(track, processor);
expect(track.setProcessor).not.toHaveBeenCalled();
});
it("does not surface a rejected setProcessor", async () => {
const track = mockTrack("live");
vi.mocked(track.setProcessor).mockRejectedValue(
new TypeError("Input track cannot be ended"),
);
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, processor);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
it("stops the processor when none is wanted", () => {
const track = mockTrack("live", processor);
applyProcessor(track, undefined);
expect(track.stopProcessor).toHaveBeenCalled();
});
it("does not surface a rejected stopProcessor", async () => {
const track = mockTrack("live", processor);
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, undefined);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
});
describe("trackProcessorSync", () => {
it("applies the processor to the current track", () => {
const track = mockTrack("live");
trackProcessorSync(
testScope(),
constant(track),
constant({ supported: true, processor }),
);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
});
+31 -13
View File
@@ -19,6 +19,7 @@ import {
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
@@ -65,6 +66,34 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
return state$;
}
/**
* Attaches or detaches the processor so that the track matches the desired
* state, without throwing.
*/
export function applyProcessor(
videoTrack: LocalVideoTrack,
processor: ProcessorWrapper<BackgroundOptions> | undefined,
): void {
if (processor && !videoTrack.getProcessor()) {
// A MediaStreamTrackProcessor cannot be constructed on an ended track
// (e.g. the camera was stopped while the processor was being applied),
// and setProcessor rejects with a TypeError. The track is going away
// anyway, so there is nothing to attach to.
if (videoTrack.mediaStreamTrack.readyState === "ended") {
logger.debug("Not attaching video processor to an ended track");
return;
}
videoTrack.setProcessor(processor).catch((e) => {
logger.warn("Failed to attach video processor", e);
});
}
if (!processor && videoTrack.getProcessor()) {
videoTrack.stopProcessor().catch((e) => {
logger.warn("Failed to stop video processor", e);
});
}
}
/**
* Updates your video tracks to always use the given processor.
*/
@@ -78,13 +107,7 @@ export const trackProcessorSync = (
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
const { processor } = processorState;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
applyProcessor(videoTrack, processorState.processor);
});
};
@@ -94,12 +117,7 @@ export const useTrackProcessorSync = (
const { processor } = useTrackProcessor();
useEffect(() => {
if (!videoTrack) return;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
applyProcessor(videoTrack, processor);
}, [processor, videoTrack]);
};