Offer no effects for the session where the pipeline cannot be built

- A segmenter that can't be built, with the model unreachable or a GPU
  its delegate refuses, took the camera down with the pipeline: the SDK
  fails the track it was attaching to, and the lobby muted camera and
  microphone, on every load while the choice was kept.
- One is now built on trial when an effect is first wanted, before the
  pipeline is attached to anything, on the CPU where the GPU refuses.
  Where it can't be built, the options are disabled with the existing
  notice for the rest of the session. The choice is kept: a browser
  update may fix what failed.
- The view model takes that verdict from the pipeline's state rather than
  asking the SDK, so both say the same, and it no longer builds a WebGL
  context of its own to ask.
- Until the trial is over, the lobby opens no camera and the call makes no
  camera track, so neither starts without the effect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-10-08 14:44:25 +02:00
co-authored by Claude Opus 5.5
parent 989bdc6815
commit 74ac3a129d
12 changed files with 308 additions and 70 deletions
+40
View File
@@ -178,6 +178,46 @@ test.describe("background effects section", () => {
}); });
}); });
test.describe("a pipeline that fails to build", () => {
test("offers no effects for the session, and keeps the choice", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.route("**/*.tflite*", async (route) => route.abort());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(
page.getByText("Background effects are not supported on this platform."),
).toBeVisible({ timeout: 30_000 });
await expect(tile).toBeDisabled();
await page.keyboard.press("Escape");
// The camera and microphone carry on without it.
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
"aria-checked",
"true",
);
await page.unroute("**/*.tflite*");
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(tile).toHaveAttribute("aria-checked", "true");
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("the chosen effect", () => { test.describe("the chosen effect", () => {
test("chosen effect survives leaving and rejoining", async ({ page }) => { test("chosen effect survives leaving and rejoining", async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
+33 -3
View File
@@ -15,7 +15,7 @@ import {
mockMuteStates, mockMuteStates,
mockMediaDevices, mockMediaDevices,
} from "../utils/test"; } from "../utils/test";
import { constant } from "../state/Behavior"; import { type Behavior, constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types"; import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { SpotlightTileViewModel } from "../state/TileViewModel";
@@ -122,6 +122,11 @@ const noPipeline = constant<BackgroundEffectsState>({
processor: undefined, processor: undefined,
}); });
/** The provider's verdict, which the SDK's check stands for here. */
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
return constant({ supported: sdkSupportMock(), processor: undefined });
}
const twoMicsAndOneCamMediaDevices = mockMediaDevices({ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: { audioInput: {
available$: constant( available$: constant(
@@ -284,7 +289,7 @@ describe("createCallFooterViewModel", () => {
testScope(), testScope(),
mockMuteStates(), mockMuteStates(),
twoMicsAndOneCamMediaDevices, twoMicsAndOneCamMediaDevices,
noPipeline, pipelineAsTheSdkSays(),
/* openSettings */ undefined, /* openSettings */ undefined,
/* hangup */ undefined, /* hangup */ undefined,
/* showLogo */ false, /* showLogo */ false,
@@ -388,6 +393,31 @@ describe("createCallFooterViewModel", () => {
expect(vm.backgroundEffectSettling$.value).toBe(false); expect(vm.backgroundEffectSettling$.value).toBe(false);
}); });
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
platformMock.mockReturnValue("desktop");
backgroundEffectSetting.setValue("blur");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffect$.value).toBe("blur");
pipeline$.next({ supported: false, processor: undefined });
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.addBackgroundImage$.value).toBeUndefined();
expect(vm.backgroundEffect$.value).toBe("none");
expect(backgroundEffectSetting.getValue()).toBe("blur");
});
it("adding is unavailable at the limit", () => { it("adding is unavailable at the limit", () => {
sdkSupportMock.mockReturnValue(true); sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject< const added$ = addedBackgrounds.added$ as BehaviorSubject<
@@ -480,7 +510,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout), buildMinimalCallViewModel(gridLayout),
mockMuteStates(), mockMuteStates(),
twoMicsAndOneCamMediaDevices, twoMicsAndOneCamMediaDevices,
noPipeline, pipelineAsTheSdkSays(),
/* reactionIdentifier */ undefined, /* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard }, { showControls: true, header: HeaderStyle.Standard },
); );
+12 -9
View File
@@ -15,10 +15,7 @@ import {
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client"; import { supportsAudioOutputSelection } from "livekit-client";
import { import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
supportsBackgroundProcessors,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -130,10 +127,14 @@ function buildDeviceBehaviors(
), ),
); );
const supported = supportsBackgroundProcessors(); // Also false once the pipeline has failed to build this session.
const supported$ = backgroundEffectsState$.pipe(
map(({ supported }) => supported === true),
distinctUntilChanged(),
);
const slow = !supportsModernBackgroundProcessors(); const slow = !supportsModernBackgroundProcessors();
const offered$ = disableSwitcher$.pipe( const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map((switcherDisabled) => !switcherDisabled && supported), map(([switcherDisabled, supported]) => !switcherDisabled && supported),
); );
// A new object for each refusal, so the same one twice is shown twice. // A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>( const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
@@ -164,8 +165,10 @@ function buildDeviceBehaviors(
), ),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select), selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
backgroundEffect$: scope.behavior( backgroundEffect$: scope.behavior(
backgroundEffectSetting.value$.pipe( combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")), map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
), ),
), ),
selectBackgroundEffect$: scope.behavior( selectBackgroundEffect$: scope.behavior(
@@ -13,13 +13,19 @@ import {
} from "@livekit/track-processors"; } from "@livekit/track-processors";
import { ImageSegmenter } from "@mediapipe/tasks-vision"; import { ImageSegmenter } from "@mediapipe/tasks-vision";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer"; import {
BackgroundEffectTransformer,
canSegment,
} from "./BackgroundEffectTransformer";
const frame = {} as VideoFrame; const frame = {} as VideoFrame;
const controller = {} as TransformStreamDefaultController<VideoFrame>; const controller = {} as TransformStreamDefaultController<VideoFrame>;
describe("BackgroundEffectTransformer", () => { describe("BackgroundEffectTransformer", () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads a background chosen before it was built", async () => { it("loads a background chosen before it was built", async () => {
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue(); vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
@@ -78,4 +84,33 @@ describe("BackgroundEffectTransformer", () => {
expect(transformer.options.blurRadius).toBeUndefined(); expect(transformer.options.blurRadius).toBeUndefined();
expect(transformer.options.imagePath).toBeUndefined(); expect(transformer.options.imagePath).toBeUndefined();
}); });
it("segments on the CPU where the GPU refuses", async () => {
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
const build = vi
.spyOn(ImageSegmenter, "createFromOptions")
.mockRejectedValueOnce(new Error("blocklisted"))
.mockResolvedValue({} as ImageSegmenter);
await new BackgroundEffectTransformer({}).init(
{} as VideoTransformerInitOptions,
);
expect(build.mock.calls.map(([, o]) => o.baseOptions?.delegate)).toEqual([
"GPU",
"CPU",
]);
});
it("says whether a segmenter can be built at all", async () => {
vi.stubGlobal("OffscreenCanvas", class {});
const close = vi.fn();
const build = vi
.spyOn(ImageSegmenter, "createFromOptions")
.mockResolvedValue({ close } as unknown as ImageSegmenter);
expect(await canSegment()).toBe(true);
expect(close).toHaveBeenCalled();
build.mockRejectedValue(new Error("no model"));
expect(await canSegment()).toBe(false);
});
}); });
+40 -11
View File
@@ -11,6 +11,7 @@ import {
VideoTransformer, VideoTransformer,
type VideoTransformerInitOptions, type VideoTransformerInitOptions,
type BackgroundOptions, type BackgroundOptions,
type SegmenterOptions,
} from "@livekit/track-processors"; } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { ImageSegmenter } from "@mediapipe/tasks-vision"; import { ImageSegmenter } from "@mediapipe/tasks-vision";
@@ -89,17 +90,10 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
inputElement: inputVideo, inputElement: inputVideo,
}); });
this.imageSegmenter = await ImageSegmenter.createFromOptions(wasmFileset, { this.imageSegmenter = await createSegmenter(
baseOptions: { this.canvas,
modelAssetPath, this.options.segmenterOptions,
delegate: "GPU", );
...this.options.segmenterOptions,
},
canvas: this.canvas,
runningMode: "VIDEO",
outputCategoryMask: true,
outputConfidenceMasks: false,
});
// BackgroundTransformer's own init applies these, and this one replaces it. // BackgroundTransformer's own init applies these, and this one replaces it.
if (this.options.imagePath) { if (this.options.imagePath) {
@@ -113,3 +107,38 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false); this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
} }
} }
/**
* Whether a segmenter can be built here, asked once before any camera depends
* on one: a pipeline that fails to build takes the camera down with it.
*/
export async function canSegment(): Promise<boolean> {
try {
(await createSegmenter(new OffscreenCanvas(1, 1))).close();
return true;
} catch (e) {
logger.warn("Background effects cannot run here", e);
return false;
}
}
/** On the GPU, or on the CPU where the GPU refuses, as a blocklisted one does. */
async function createSegmenter(
canvas: HTMLCanvasElement | OffscreenCanvas | undefined,
segmenterOptions?: SegmenterOptions,
): Promise<ImageSegmenter> {
const build = async (delegate: "GPU" | "CPU"): Promise<ImageSegmenter> =>
ImageSegmenter.createFromOptions(wasmFileset, {
baseOptions: { modelAssetPath, delegate, ...segmenterOptions },
canvas,
runningMode: "VIDEO",
outputCategoryMask: true,
outputConfidenceMasks: false,
});
try {
return await build("GPU");
} catch (e) {
logger.warn("Segmenting on the CPU, as the GPU refused", e);
return build("CPU");
}
}
+2 -1
View File
@@ -65,7 +65,8 @@ vi.mock("./backgroundImages", async () => {
}; };
}); });
vi.mock("./BackgroundEffectTransformer", () => ({ vi.mock("./BackgroundEffectTransformer", () => ({
BackgroundEffectTransformer: vi.fn(), BackgroundEffectTransformer: vi.fn(function () {}),
canSegment: async (): Promise<boolean> => Promise.resolve(true),
})); }));
const processor = {} as ProcessorWrapper<BackgroundOptions>; const processor = {} as ProcessorWrapper<BackgroundOptions>;
+7 -1
View File
@@ -24,7 +24,10 @@ import {
} from "rxjs"; } from "rxjs";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings"; import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer"; import {
BackgroundEffectTransformer,
canSegment,
} from "./BackgroundEffectTransformer";
import { type SyncedCameraTrack } from "./cameraTrack"; import { type SyncedCameraTrack } from "./cameraTrack";
import { OneStepPipeline } from "./OneStepPipeline"; import { OneStepPipeline } from "./OneStepPipeline";
import { addedBackgrounds } from "./backgroundImages"; import { addedBackgrounds } from "./backgroundImages";
@@ -47,6 +50,8 @@ export type BackgroundEffectsState = {
settling?: boolean; settling?: boolean;
/** The camera this provider's pipeline is synced to. */ /** The camera this provider's pipeline is synced to. */
cameraTrack?: SyncedCameraTrack; cameraTrack?: SyncedCameraTrack;
/** While asking whether the pipeline can be built, before it is attached. */
preparing?: boolean;
}; };
const BackgroundEffectsContext = createContext<BackgroundEffects | undefined>( const BackgroundEffectsContext = createContext<BackgroundEffects | undefined>(
@@ -157,6 +162,7 @@ export function createAppBackgroundEffects(
effect$: backgroundEffectSetting.value$, effect$: backgroundEffectSetting.value$,
setEffect: backgroundEffectSetting.setValue, setEffect: backgroundEffectSetting.setValue,
added$: addedBackgrounds.added$, added$: addedBackgrounds.added$,
canSegment,
pipeline: new OneStepPipeline(transformer, "background-effect"), pipeline: new OneStepPipeline(transformer, "background-effect"),
transformer, transformer,
}); });
+18 -4
View File
@@ -9,6 +9,7 @@ import {
type FC, type FC,
useCallback, useCallback,
useMemo, useMemo,
useRef,
useState, useState,
type JSX, type JSX,
useEffect, useEffect,
@@ -137,10 +138,15 @@ export const LobbyView: FC<Props> = ({
}, },
); );
const { processor } = useBackgroundEffects(); const { processor, preparing } = useBackgroundEffects();
const backgroundEffectsState$ = useBackgroundEffectsState$(); const backgroundEffectsState$ = useBackgroundEffectsState$();
const initialProcessor = useInitial(() => processor); // Taken once it is known whether the pipeline builds, and then kept, as
// useInitial keeps a value: one attached later is synced below.
const initialProcessorRef = useRef<{ value: typeof processor }>(undefined);
if (!preparing) initialProcessorRef.current ??= { value: processor };
const initialProcessor = initialProcessorRef.current?.value;
const processorTaken = initialProcessorRef.current !== undefined;
const localTrackOptions = useMemo<CreateLocalTracksOptions>( const localTrackOptions = useMemo<CreateLocalTracksOptions>(
() => ({ () => ({
// The only reason we request audio here is to get the audio permission // The only reason we request audio here is to get the audio permission
@@ -150,12 +156,20 @@ export const LobbyView: FC<Props> = ({
// We also pass in a clone because livekit mutates the object passed in, // We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render. // which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions), audio: Object.assign({}, initialAudioOptions),
video: videoEnabled && { // Not opened before then, so that the preview never shows the room.
video: videoEnabled &&
processorTaken && {
deviceId: videoInputId, deviceId: videoInputId,
processor: initialProcessor, processor: initialProcessor,
}, },
}), }),
[initialAudioOptions, videoEnabled, videoInputId, initialProcessor], [
initialAudioOptions,
videoEnabled,
processorTaken,
videoInputId,
initialProcessor,
],
); );
const onError = useCallback( const onError = useCallback(
+40 -18
View File
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, distinctUntilChanged, map } from "rxjs"; import { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs";
import { type BackgroundProcessorWrapper } from "@livekit/track-processors"; import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
import { import {
@@ -47,8 +47,10 @@ function fakePipeline(): {
}; };
} }
/** One letter per state: idle, waiting for a frame, attached. */ /** One letter per state: idle, preparing, waiting for a frame, attached. */
function letter(state: BackgroundEffectsState): string { function letter(state: BackgroundEffectsState): string {
if (state.supported === false) return "x";
if (state.preparing) return "p";
if (state.processor === undefined) return "i"; if (state.processor === undefined) return "i";
return state.settling ? "w" : "a"; return state.settling ? "w" : "a";
} }
@@ -56,14 +58,17 @@ function letter(state: BackgroundEffectsState): string {
describe("the pipeline's state", () => { describe("the pipeline's state", () => {
function testState({ function testState({
effect, effect,
answer = "-y",
firstFrame = "", firstFrame = "",
expected, expected,
}: { }: {
effect: string; effect: string;
/** When the trial build answers, from being asked. */
answer?: string;
firstFrame?: string; firstFrame?: string;
expected: string; expected: string;
}): void { }): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
const transformer = { const transformer = {
onFirstFrame: undefined as (() => void) | undefined, onFirstFrame: undefined as (() => void) | undefined,
}; };
@@ -72,6 +77,7 @@ describe("the pipeline's state", () => {
effect$: behavior(effect, { n: "none", b: "blur" }), effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(), setEffect: vi.fn(),
added$: new BehaviorSubject<AddedBackground[] | undefined>([]), added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
canSegment: () => cold(answer, { y: true, n: false }),
pipeline: fakePipeline().pipeline, pipeline: fakePipeline().pipeline,
transformer, transformer,
}); });
@@ -84,15 +90,21 @@ describe("the pipeline's state", () => {
it("defaults to no effect", () => testState({ effect: "n", expected: "i" })); it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
it("attaches only once it is known the pipeline builds", () =>
testState({ effect: "nb", answer: "--y", expected: "ip-w" }));
it("offers none where the pipeline fails to build", () =>
testState({ effect: "nb", answer: "-n", expected: "ipx" }));
it("waits from the first effect until a frame carries it", () => it("waits from the first effect until a frame carries it", () =>
testState({ testState({
effect: " nb-n-b", effect: " nb---n-b",
firstFrame: " --f", firstFrame: "---f",
expected: " iwa", expected: " ipwa",
})); }));
it("attaches on first use and stays attached", () => it("attaches on first use and stays attached", () =>
testState({ effect: "nb-n", firstFrame: "--f", expected: "iwa" })); testState({ effect: "nb--n", firstFrame: "---f", expected: "ipwa" }));
}); });
describe("background effects", () => { describe("background effects", () => {
@@ -109,6 +121,7 @@ describe("background effects", () => {
effect$, effect$,
setEffect, setEffect,
added$, added$,
canSegment: () => of(true),
pipeline: fake.pipeline, pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined }, transformer: { onFirstFrame: undefined },
...options, ...options,
@@ -118,8 +131,6 @@ describe("background effects", () => {
effect$.next(raw); effect$.next(raw);
await flushPromises(); await flushPromises();
}; };
const blur = async (on: boolean): Promise<void> =>
choose(on ? "blur" : "none");
beforeEach(() => { beforeEach(() => {
effect$ = new BehaviorSubject("none"); effect$ = new BehaviorSubject("none");
@@ -128,6 +139,17 @@ describe("background effects", () => {
fake = fakePipeline(); fake = fakePipeline();
}); });
it("offers none for the session where the pipeline fails to build", async () => {
const effects = build({ canSegment: () => of(false) });
await choose("blur");
expect(effects.state$.value.supported).toBe(false);
expect(effects.state$.value.processor).toBeUndefined();
expect(effects.state$.value.settling).toBe(false);
// A browser update may fix it, so the next session asks again.
expect(effect$.value).toBe("blur");
});
it("puts a shipped background on as that picture", async () => { it("puts a shipped background on as that picture", async () => {
build(); build();
await choose(`image:${shippedBackgrounds[0].id}`); await choose(`image:${shippedBackgrounds[0].id}`);
@@ -141,10 +163,10 @@ describe("background effects", () => {
it("switches in place rather than reattaching", async () => { it("switches in place rather than reattaching", async () => {
const effects = build(); const effects = build();
await blur(true); await choose("blur");
const pipeline = effects.state$.value.processor; const pipeline = effects.state$.value.processor;
await blur(false); await choose("none");
await blur(true); await choose("blur");
expect(effects.state$.value.processor).toBe(pipeline); expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual([ expect(fake.switches).toEqual([
@@ -157,18 +179,18 @@ describe("background effects", () => {
it("keeps switching after a switch fails", async () => { it("keeps switching after a switch fails", async () => {
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost")); vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
build(); build();
await blur(true); await choose("blur");
await blur(false); await choose("none");
expect(fake.switches).toEqual([{ mode: "disabled" }]); expect(fake.switches).toEqual([{ mode: "disabled" }]);
}); });
it("switches one at a time, skipping those overtaken", async () => { it("switches one at a time, skipping those overtaken", async () => {
const finished = fake.holdNext(); const finished = fake.holdNext();
build(); build();
await blur(true); await choose("blur");
await blur(false); await choose("none");
await blur(true); await choose("blur");
await blur(false); await choose("none");
expect(fake.switches).toHaveLength(1); expect(fake.switches).toHaveLength(1);
finished(); finished();
+34 -7
View File
@@ -8,9 +8,15 @@ Please see LICENSE in the repository root for full details.
import { import {
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
first,
from,
map, map,
Observable, Observable,
type ObservableInput,
of,
scan, scan,
startWith,
switchMap,
} from "rxjs"; } from "rxjs";
import { import {
type BackgroundProcessorWrapper, type BackgroundProcessorWrapper,
@@ -41,6 +47,8 @@ export interface BackgroundEffectsOptions {
setEffect: (id: EffectId) => void; setEffect: (id: EffectId) => void;
/** The backgrounds the device keeps, undefined until it has said. */ /** The backgrounds the device keeps, undefined until it has said. */
added$: Behavior<AddedBackground[] | undefined>; added$: Behavior<AddedBackground[] | undefined>;
/** Builds a segmenter on trial, and answers whether it could. */
canSegment: () => ObservableInput<boolean>;
/** The background processor pipeline to be switched between effects. */ /** The background processor pipeline to be switched between effects. */
pipeline: BackgroundProcessorWrapper; pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */ /** Tells, once, that a frame carrying an effect has been drawn. */
@@ -60,6 +68,7 @@ export function createBackgroundEffects(
effect$, effect$,
setEffect, setEffect,
added$, added$,
canSegment,
pipeline, pipeline,
transformer, transformer,
}: BackgroundEffectsOptions, }: BackgroundEffectsOptions,
@@ -67,9 +76,11 @@ export function createBackgroundEffects(
// The camera the pipeline is synced to. // The camera the pipeline is synced to.
const cameraTrack = new SyncedCameraTrack(); const cameraTrack = new SyncedCameraTrack();
const choice$ = effect$.pipe(map(parseEffect)); const choice$ = effect$.pipe(map(parseEffect));
const wanted$ = choice$.pipe( const wanted$ = scope.behavior(
choice$.pipe(
map((effect) => effect.kind !== "none"), map((effect) => effect.kind !== "none"),
distinctUntilChanged(), distinctUntilChanged(),
),
); );
combineLatest([choice$, added$]) combineLatest([choice$, added$])
@@ -85,6 +96,18 @@ export function createBackgroundEffects(
setEffect("none"); setEffect("none");
}); });
// Asked once an effect is first wanted. A browser update may fix what
// fails, so the choice is kept for the next session rather than cleared.
const buildable$ = scope.behavior<boolean | undefined>(
supported
? wanted$.pipe(
first(Boolean),
switchMap(() => from(canSegment())),
startWith(undefined),
)
: of(undefined),
);
const drewAFrame$ = scope.behavior( const drewAFrame$ = scope.behavior(
new Observable<boolean>((subscriber) => { new Observable<boolean>((subscriber) => {
subscriber.next(false); subscriber.next(false);
@@ -95,19 +118,23 @@ export function createBackgroundEffects(
}), }),
); );
// In one step, so that nothing sees the pipeline neither preparing nor
// attached.
const state$ = scope.behavior( const state$ = scope.behavior(
combineLatest([wanted$, drewAFrame$]).pipe( combineLatest([wanted$, buildable$, drewAFrame$]).pipe(
scan<[boolean, boolean], BackgroundEffectsState>( scan<[boolean, boolean | undefined, boolean], BackgroundEffectsState>(
(previous, [wanted, drewAFrame]) => { (previous, [wanted, buildable, drewAFrame]) => {
// Attached the first time an effect is wanted and never detached // Attached the first time an effect is wanted and never detached
// after, so someone who never turns one on pays for none of it. // after, so someone who never turns one on pays for none of it.
const enable = const enable =
previous.processor !== undefined || (supported && wanted); previous.processor !== undefined || (wanted && buildable === true);
const preparing = supported && wanted && buildable === undefined;
return { return {
supported, supported: supported && buildable !== false,
processor: enable ? pipeline : undefined, processor: enable ? pipeline : undefined,
settling: enable && !drewAFrame, settling: (preparing || enable) && !drewAFrame,
cameraTrack, cameraTrack,
preparing,
}; };
}, },
{ supported, processor: undefined }, { supported, processor: undefined },
@@ -451,6 +451,18 @@ describe("turning the camera on with an effect chosen", () => {
expect(madeWith).toEqual([processor]); expect(madeWith).toEqual([processor]);
}); });
it("waits to make a camera track until it is known the pipeline builds", async () => {
state$.next({ supported: true, processor: undefined, preparing: true });
await publisher.createAndSetupTracks();
videoEnabled$.next(true);
await flushPromises();
expect(madeWith).toEqual([]);
state$.next({ supported: true, processor, preparing: false });
await flushPromises();
expect(madeWith).toEqual([processor]);
});
it("replaces a camera track turned off before the effect was chosen", async () => { it("replaces a camera track turned off before the effect was chosen", async () => {
state$.next({ supported: true, processor }); state$.next({ supported: true, processor });
const stopped = Object.assign(Object.create(LocalVideoTrack.prototype), { const stopped = Object.assign(Object.create(LocalVideoTrack.prototype), {
@@ -14,6 +14,8 @@ import {
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { import {
filter,
firstValueFrom,
map, map,
NEVER, NEVER,
type Observable, type Observable,
@@ -192,6 +194,7 @@ export class Publisher {
// We are using the `ParticipantEvent.LocalTrackPublished` to be notified // We are using the `ParticipantEvent.LocalTrackPublished` to be notified
// when tracks are actually published, and at that point // when tracks are actually published, and at that point
// we can pause upstream if needed (depending on if startPublishing has been called). // we can pause upstream if needed (depending on if startPublishing has been called).
const enableTracks = (): void => {
if (audio && video) { if (audio && video) {
// Enable both at once in order to have a single permission prompt! // Enable both at once in order to have a single permission prompt!
void lkRoom.localParticipant.enableCameraAndMicrophone(); void lkRoom.localParticipant.enableCameraAndMicrophone();
@@ -200,6 +203,10 @@ export class Publisher {
} else if (video) { } else if (video) {
void lkRoom.localParticipant.setCameraEnabled(true); void lkRoom.localParticipant.setCameraEnabled(true);
} }
};
if (video && this.backgroundEffectsState$.value.preparing)
void this.pipelineKnown().then(enableTracks);
else enableTracks();
return Promise.resolve(); return Promise.resolve();
} }
@@ -415,7 +422,9 @@ export class Publisher {
this.muteStates.video.setHandler(async (enable) => { this.muteStates.video.setHandler(async (enable) => {
try { try {
this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`); this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`);
const { processor } = this.backgroundEffectsState$.value; const { processor } = enable
? await this.pipelineKnown()
: this.backgroundEffectsState$.value;
if (enable && processor) await this.dropCameraWithoutEffect(lkRoom); if (enable && processor) await this.dropCameraWithoutEffect(lkRoom);
await lkRoom.localParticipant.setCameraEnabled(enable); await lkRoom.localParticipant.setCameraEnabled(enable);
// Unmute will restart the track if it was paused upstream, // Unmute will restart the track if it was paused upstream,
@@ -444,6 +453,16 @@ export class Publisher {
await lkRoom.localParticipant.unpublishTrack(track); await lkRoom.localParticipant.unpublishTrack(track);
} }
/**
* The pipeline's state once it is known whether it builds: a camera track
* made before then would start without the effect.
*/
private async pipelineKnown(): Promise<BackgroundEffectsState> {
return firstValueFrom(
this.backgroundEffectsState$.pipe(filter((s) => !s.preparing)),
);
}
private observeBackgroundEffects( private observeBackgroundEffects(
scope: ObservableScope, scope: ObservableScope,
room: LivekitRoom, room: LivekitRoom,