mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
- The camera menu's blur toggle becomes a Background effects section under the cameras: no effect, blur and the two shipped backgrounds as one radio choice of tiles, three across. It scrolls with the device list, so it stays reachable in a short call, and choosing keeps the menu open. The lobby gets it too, as both render the same footer. - The footer's view model gives the options, the notice and the actions; the footer only names them. - Each tile is Compound's MenuItem as a menuitemradio, styled as a tile. Compound makes it an item of the dropdown, so the arrow keys walk the tiles with the device rows, and takes its tap in the phone drawer. A tile that can't be chosen has no select handler, so the keyboard passes over it. - Where the browser can't run effects the tiles are shown, disabled and explained by a notice the group is described by. No effect stays choosable and shows as in force. The notice is in the scrolling list, so a short call doesn't push the menu out of it. - Section headings are raised, or the positioned tiles scroll over the one they sit under; raised within the list only, so the menu's frame stays over them. Tiles clip with overflow: clip; with hidden they are scroll containers, which focus scrolls into view without their border. - End to end on Chromium and Firefox: a shipped background chosen in the lobby shows in the preview, and None after Blur draws nothing, counted in WebGL draws (Blur: about 1400 a second on Chromium, 2300 on Firefox). - The Quick Audio Menu's test of the camera menu keeps its name and now finds blur among the effects rather than as a toggle. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
/*
|
|
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 { expect, test, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { SpaHelpers } from "./spa-helpers.ts";
|
|
|
|
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
|
// so it rightly offers none of them.
|
|
test.skip(
|
|
({ browserName }) => browserName === "firefox",
|
|
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
|
);
|
|
|
|
type Colour = [number, number, number];
|
|
|
|
test.describe("background effects", () => {
|
|
test("pre-join preview shows the chosen effect", async ({ page }) => {
|
|
test.slow();
|
|
await page.goto("/");
|
|
await SpaHelpers.createCall(page, "Effect user", "Background effects");
|
|
const preview = page.locator("video").first();
|
|
const camera = await averageColour(preview);
|
|
|
|
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
|
const tile = page
|
|
.getByRole("group", { name: "Background effects" })
|
|
.getByRole("menuitemradio", { name: "Background 1" });
|
|
await tile.click();
|
|
await expect(tile).toHaveAttribute("aria-checked", "true");
|
|
|
|
// The fake camera shows no person, so the whole frame is background: the
|
|
// preview takes on the picture's colours and leaves the camera's.
|
|
const picture = await averageColour(tile.locator("img"));
|
|
// Long enough for the first effect of a session to be built.
|
|
await expect
|
|
.poll(async () => distance(await averageColour(preview), picture), {
|
|
timeout: 60_000,
|
|
})
|
|
.toBeLessThan(distance(camera, picture) / 2);
|
|
});
|
|
});
|
|
|
|
test.describe("no effect", () => {
|
|
test("draws nothing, even after blurring", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await countDraws(page);
|
|
await page.goto("/");
|
|
await SpaHelpers.createCall(page, "Idle user", "No effect after blur");
|
|
|
|
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
|
const effects = page.getByRole("group", { name: "Background effects" });
|
|
await effects.getByRole("menuitemradio", { name: "Blur" }).click();
|
|
// Long enough for the first effect of a session to be built.
|
|
await expect
|
|
.poll(async () => drawsPerSecond(page), { timeout: 60_000 })
|
|
.toBeGreaterThan(0);
|
|
const blurred = await drawsPerSecond(page);
|
|
|
|
await effects.getByRole("menuitemradio", { name: "None" }).click();
|
|
// A frame already on its way may still be drawn.
|
|
await expect.poll(async () => drawsPerSecond(page)).toBe(0);
|
|
test.info().annotations.push({
|
|
type: "WebGL draws per second",
|
|
description: `blurred ${blurred}, none 0`,
|
|
});
|
|
});
|
|
});
|
|
|
|
/** Counts every WebGL draw on the page, the segmenter's and the effect's. */
|
|
async function countDraws(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const counter = { draws: 0 };
|
|
(window as unknown as { webglDraws: typeof counter }).webglDraws = counter;
|
|
for (const name of ["drawArrays", "drawElements"] as const) {
|
|
const draw = WebGL2RenderingContext.prototype[name] as (
|
|
...args: unknown[]
|
|
) => void;
|
|
WebGL2RenderingContext.prototype[name] = function (
|
|
this: WebGL2RenderingContext,
|
|
...args: unknown[]
|
|
): void {
|
|
counter.draws += 1;
|
|
draw.apply(this, args);
|
|
} as never;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** WebGL draws over the next second. */
|
|
async function drawsPerSecond(page: Page): Promise<number> {
|
|
return page.evaluate(async () => {
|
|
const counter = (window as unknown as { webglDraws: { draws: number } })
|
|
.webglDraws;
|
|
const before = counter.draws;
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
return counter.draws - before;
|
|
});
|
|
}
|
|
|
|
/** The mean colour of a video's current frame or an image, drawn small. */
|
|
async function averageColour(element: Locator): Promise<Colour> {
|
|
return element.evaluate(
|
|
async (source: HTMLVideoElement | HTMLImageElement) => {
|
|
if (source instanceof HTMLImageElement) await source.decode();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = canvas.height = 32;
|
|
const context = canvas.getContext("2d")!;
|
|
context.drawImage(source, 0, 0, 32, 32);
|
|
const { data } = context.getImageData(0, 0, 32, 32);
|
|
const sum = [0, 0, 0];
|
|
for (let i = 0; i < data.length; i += 4)
|
|
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
|
const pixels = data.length / 4;
|
|
return sum.map((s) => s / pixels) as Colour;
|
|
},
|
|
);
|
|
}
|
|
|
|
function distance(a: Colour, b: Colour): number {
|
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
}
|