From bb11dda3658a26ed79e781ab36c14508fa297cdf Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 24 Sep 2026 18:09:16 +0200 Subject: [PATCH] Hold the chosen effect across calls, and forget one that can't run - A remembered effect this browser can't run is cleared rather than kept, as the menu already showed no effect in force there. - A camera turned off and on again with an effect chosen gets the same pipeline as its track arrives, neither rebuilt nor switched again. - End to end, on Chromium and Firefox: the effect chosen before a call is still chosen, and worn, in the next call after leaving; and a second tab still shuts the first down with the existing "Opened in another tab" error while an effect runs. - Switching camera and sharing a screen are checked by hand: they need two cameras and a screen, which the fake devices don't give yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- playwright/background-effects.spec.ts | 63 ++++++++++++++++++++ src/livekit/BackgroundEffectsContext.test.ts | 26 +++++++- src/livekit/BackgroundEffectsContext.tsx | 1 + src/state/BackgroundEffects.test.ts | 11 ++++ src/state/BackgroundEffects.ts | 16 ++++- 5 files changed, 115 insertions(+), 2 deletions(-) diff --git a/playwright/background-effects.spec.ts b/playwright/background-effects.spec.ts index 4be24c1a5..e71ebab7e 100644 --- a/playwright/background-effects.spec.ts +++ b/playwright/background-effects.spec.ts @@ -151,6 +151,69 @@ test.describe("background effects section", () => { }); }); +test.describe("the chosen effect", () => { + test("chosen effect survives leaving and rejoining", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await SpaHelpers.createCall(page, "Returning user", "Effect remembered"); + const { camera, picture } = await chooseAndWear(page, "Background 1"); + await page.getByTestId("lobby_joinCall").click(); + await page.getByTestId("incall_leave").click(); + await expect(page.getByRole("heading")).toContainText( + "your call has ended", + ); + + await page.goto("/"); + await page.getByTestId("home_callName").fill("Effect remembered again"); + await page.getByTestId("home_go").click(); + await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); + await page.getByRole("button", { name: "Camera", exact: true }).click(); + const tile = page + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio", { name: "Background 1" }); + await expect(tile).toHaveAttribute("aria-checked", "true"); + const preview = page.locator("video").first(); + await expect + .poll(async () => distance(await averageColour(preview), picture), { + timeout: 60_000, + }) + .toBeLessThan(distance(camera, picture) / 2); + }); + + test("second session still shows the existing error", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await SpaHelpers.createCall(page, "Two tabs", "Effect in two tabs"); + await chooseAndWear(page, "Background 1"); + + const second = await page.context().newPage(); + await second.goto("/"); + await expect( + page.getByRole("heading", { name: "Opened in another tab" }), + ).toBeVisible(); + }); +}); + +/** Chooses an effect in the lobby, and waits until the preview wears it. */ +async function chooseAndWear( + page: Page, + name: string, +): Promise<{ camera: Colour; picture: Colour }> { + const preview = page.locator("video").first(); + const camera = await cameraColour(preview); + await page.getByRole("button", { name: "Camera", exact: true }).click(); + const tile = page.getByRole("menuitemradio", { name }); + await tile.click(); + const picture = await averageColour(tile.locator("img")); + await expect + .poll(async () => distance(await averageColour(preview), picture), { + timeout: 60_000, + }) + .toBeLessThan(distance(camera, picture) / 2); + await page.keyboard.press("Escape"); + return { camera, picture }; +} + test.describe("joining with a background chosen", () => { test("publishes no frame of the room", async ({ browser }) => { // Two first builds of the pipeline, one of them held back. diff --git a/src/livekit/BackgroundEffectsContext.test.ts b/src/livekit/BackgroundEffectsContext.test.ts index 62b3588fd..5dfed6163 100644 --- a/src/livekit/BackgroundEffectsContext.test.ts +++ b/src/livekit/BackgroundEffectsContext.test.ts @@ -28,6 +28,7 @@ import { backgroundEffect } from "../settings/settings"; import { constant } from "../state/Behavior"; import { flushPromises, testScope } from "../utils/test"; +const sdkSupportMock = vi.hoisted(() => vi.fn(() => true)); const pipelines = vi.hoisted(() => ({ built: 0, destroyed: 0, @@ -48,7 +49,7 @@ vi.mock("@livekit/track-processors", () => ({ return Promise.resolve(); }); }), - supportsBackgroundProcessors: (): boolean => true, + supportsBackgroundProcessors: (): boolean => sdkSupportMock(), })); const platformMock = vi.hoisted(() => ({ platform: "desktop" })); vi.mock("../Platform", () => platformMock); @@ -181,6 +182,7 @@ describe("BackgroundEffectsProvider", () => { pipelines.destroyed = 0; pipelines.switches = []; platformMock.platform = "desktop"; + sdkSupportMock.mockReturnValue(true); backgroundEffect.setValue("none"); }); afterEach(() => backgroundEffect.setValue("none")); @@ -199,6 +201,28 @@ describe("BackgroundEffectsProvider", () => { expect(track.stopProcessor).not.toHaveBeenCalled(); }); + it("camera on with an effect chosen publishes nothing unprocessed", async () => { + const view = render(surfaces(cameraTrack())); + await blur(true); + const pipeline = latest().processor; + + // Camera off, then on again: a new track, given the pipeline as it + // arrives, and the pipeline neither rebuilt nor switched again. + await act(async () => { + view.rerender(surfaces(null)); + await flushPromises(); + }); + const again = cameraTrack(); + await act(async () => { + view.rerender(surfaces(again)); + await flushPromises(); + }); + expect(again.setProcessor).toHaveBeenCalledTimes(1); + expect(again.setProcessor).toHaveBeenCalledWith(pipeline); + expect(pipelines.built).toBe(1); + expect(pipelines.switches).toHaveLength(1); + }); + it("runs on a phone whose browser can run it", async () => { platformMock.platform = "ios"; const track = cameraTrack(); diff --git a/src/livekit/BackgroundEffectsContext.tsx b/src/livekit/BackgroundEffectsContext.tsx index c974e57bb..206499441 100644 --- a/src/livekit/BackgroundEffectsContext.tsx +++ b/src/livekit/BackgroundEffectsContext.tsx @@ -123,6 +123,7 @@ export function createAppBackgroundEffects( return createBackgroundEffects(scope, { supported: supportsBackgroundProcessors(), effect$: backgroundEffectSetting.value$, + setEffect: backgroundEffectSetting.setValue, pipeline: new OneStepPipeline(transformer, "background-effect"), transformer, }); diff --git a/src/state/BackgroundEffects.test.ts b/src/state/BackgroundEffects.test.ts index 669bba3f9..1f0f34f82 100644 --- a/src/state/BackgroundEffects.test.ts +++ b/src/state/BackgroundEffects.test.ts @@ -69,6 +69,7 @@ describe("the pipeline's state", () => { const effects = createBackgroundEffects(testScope(), { supported: true, effect$: behavior(effect, { n: "none", b: "blur" }), + setEffect: vi.fn(), pipeline: fakePipeline().pipeline, transformer, }); @@ -94,6 +95,7 @@ describe("the pipeline's state", () => { describe("background effects", () => { let effect$: BehaviorSubject; + let setEffect: (raw: string) => void; let fake: ReturnType; function build( @@ -102,6 +104,7 @@ describe("background effects", () => { return createBackgroundEffects(testScope(), { supported: true, effect$, + setEffect, pipeline: fake.pipeline, transformer: { onFirstFrame: undefined }, ...options, @@ -116,6 +119,7 @@ describe("background effects", () => { beforeEach(() => { effect$ = new BehaviorSubject("none"); + setEffect = vi.fn((raw: string) => effect$.next(raw)); fake = fakePipeline(); }); @@ -169,4 +173,11 @@ describe("background effects", () => { { mode: "disabled" }, ]); }); + + it("clears a remembered effect when it cannot be honoured", () => { + effect$.next(`image:${shippedBackgrounds[0].id}`); + const effects = build({ supported: false }); + expect(effect$.value).toBe("none"); + expect(effects.state$.value.processor).toBeUndefined(); + }); }); diff --git a/src/state/BackgroundEffects.ts b/src/state/BackgroundEffects.ts index 850801ac7..5db0a52ae 100644 --- a/src/state/BackgroundEffects.ts +++ b/src/state/BackgroundEffects.ts @@ -25,6 +25,7 @@ import { type BackgroundEffectsState } from "../livekit/BackgroundEffectsContext import { type BackgroundEffect, blurRadius, + type EffectId, imagePathFor, parseEffect, } from "../livekit/backgroundEffects"; @@ -34,6 +35,8 @@ export interface BackgroundEffectsOptions { supported: boolean; /** The effect chosen, as the setting stores it. */ effect$: Behavior; + /** Stores a choice, to forget one that can't be honoured. */ + setEffect: (id: EffectId) => void; /** The background processor pipeline to be switched between effects. */ pipeline: BackgroundProcessorWrapper; /** Tells, once, that a frame carrying an effect has been drawn. */ @@ -48,7 +51,13 @@ export interface BackgroundEffects { /** Switches the pipeline as the choice changes, for as long as the scope lasts. */ export function createBackgroundEffects( scope: ObservableScope, - { supported, effect$, pipeline, transformer }: BackgroundEffectsOptions, + { + supported, + effect$, + setEffect, + pipeline, + transformer, + }: BackgroundEffectsOptions, ): BackgroundEffects { const choice$ = effect$.pipe(map(parseEffect)); const wanted$ = choice$.pipe( @@ -56,6 +65,11 @@ export function createBackgroundEffects( distinctUntilChanged(), ); + choice$.pipe(scope.bind()).subscribe((effect) => { + if (effect.kind === "none") return; + if (!supported) setEffect("none"); + }); + const drewAFrame$ = scope.behavior( new Observable((subscriber) => { subscriber.next(false);