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) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-10-08 14:35:24 +02:00
co-authored by Claude Opus 5.5
parent 0a6780be24
commit bb11dda365
5 changed files with 115 additions and 2 deletions
+63
View File
@@ -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.
+25 -1
View File
@@ -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();
+1
View File
@@ -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,
});
+11
View File
@@ -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<string>;
let setEffect: (raw: string) => void;
let fake: ReturnType<typeof fakePipeline>;
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();
});
});
+15 -1
View File
@@ -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<string>;
/** 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<boolean>((subscriber) => {
subscriber.next(false);