diff --git a/src/livekit/TrackProcessorContext.test.ts b/src/livekit/TrackProcessorContext.test.ts new file mode 100644 index 000000000..732fa5308 --- /dev/null +++ b/src/livekit/TrackProcessorContext.test.ts @@ -0,0 +1,62 @@ +/* +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 } from "./TrackProcessorContext"; + +const processor = {} as ProcessorWrapper; + +function mockTrack( + readyState: MediaStreamTrackState, + current?: ProcessorWrapper, +): 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 new Promise((r) => setTimeout(r, 0)); + 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(); + }); +}); diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index 21cd609ea..96897929f 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -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 { return state$; } +/** + * Attaches or detaches the processor so that the track matches the desired + * state, without throwing. + */ +export function applyProcessor( + videoTrack: LocalVideoTrack, + processor: ProcessorWrapper | 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]); };