Compare commits

..
Author SHA1 Message Date
Timo K. b0aba17e83 fix race in processor setup vs camera start 2026-11-06 16:52:46 +01:00
Timo K.andClaude Fable 5.1 8c5cc2847a Test the session where the pipeline cannot be built
- The effects state's timeline through preparing, settling and attached,
  and that a failed trial build withholds the effects for the session while
  keeping the choice.
- The footer offers nothing once the pipeline fails to build, and passes
  on the wait as the state's single wait field.
- The call leaves the camera out of the join while the pipeline is
  prepared, publishes it once that is known, and turns on a camera turned
  off before the processor was chosen; the lobby opens no camera meanwhile.
  Both view models are driven by one state subject.
- The transformer's trial build answers whether a segmenter can be made.
- End to end, a browser whose pipeline cannot be built sees the effects
  withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 15:28:30 +01:00
Timo K.andClaude Opus 5.5 21ea4b7175 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.

Rebased onto main, where the Publisher is gone and the call view model
publishes the camera through the SDK:
- CallViewModel takes videoProcessorPreparing$. While it is true, the
  camera's mute handler waits before making a track, and the join leaves
  the camera out of its requests (initialPublishRequests); the view
  model
  publishes it once it is known whether the pipeline builds. The
  Publisher's test became CallViewModel tests.

Cherry-picked from spec/background-effects/12-unbuildable-pipeline onto
toger5/background-effect/camera-preview, where the camera menu's preview
takes the camera from the footer's caller rather than from a track
synced
on the background-effects state. The footer factories take the
pipeline's
state again, as this commit needs its verdict and not only the settling
flag; the synced camera and the preview's wrapper type are not brought
along.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-06 15:28:30 +01:00
Timo K. e3b6847994 Show the self preview in the video mute button context menu 2026-11-06 13:30:36 +01:00
Timo K. 9f0883fe44 Hand the camera track to the footer view model 2026-11-06 13:30:36 +01:00
9 changed files with 98 additions and 96 deletions
+3 -5
View File
@@ -26,7 +26,8 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
@@ -39,10 +40,7 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
+3 -5
View File
@@ -26,7 +26,8 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
@@ -39,10 +40,7 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
-2
View File
@@ -48,8 +48,6 @@ Test what a user observes: the peer sees the change, it survives a reconnect, it
right after a reload. Reuse `playwright/spa-helpers.ts`,
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
To mark new release announcements as viewed, add them in `backend/ew.test*.config.json`.
```sh
pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
+38 -1
View File
@@ -29,10 +29,47 @@ export interface MyFixtures {
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
}
// Minimal config.json for the local element-web instance
const CONFIG_JSON = {
default_server_config: {
"m.homeserver": {
base_url: "https://synapse.m.localhost",
server_name: "synapse.m.localhost",
},
},
element_call: {
participant_limit: 8,
brand: "Element Call",
},
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
feature_group_calls: true,
},
// the location tests want a map style url.
map_style_url:
"https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
feature_element_call_video_rooms: true,
feature_video_rooms: true,
feature_group_calls: true,
},
};
export const widgetTest = test.extend<MyFixtures>({
// allow per-test override: `widgetTest.use({ callType: "dm" })`
callType: ["room", { option: true }],
asWidget: async ({ browser, callType }, pUse) => {
asWidget: async ({ browser, context, callType }, pUse) => {
await context.route(`http://localhost:8081/config.json*`, async (route) => {
await route.fulfill({ json: CONFIG_JSON });
});
const brooksDisplayName = `brooks_${Date.now()}`;
const whistlerDisplayName = `whistler_${Date.now()}`;
+34 -10
View File
@@ -153,10 +153,9 @@ export class TestHelpers {
return { page, clientHandle, mxId };
}
// Dismisses the toasts a fresh device gets after sign-in.
//
// Toasts stack and only the top one can be dismissed, so look
// at what is on top each time.
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts: {
title: string;
@@ -164,7 +163,6 @@ export class TestHelpers {
confirm?: string;
}[] = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Verify this device", button: "Later" },
{ title: "Back up your chats", button: "Not now" },
{
title: "Turn on key storage",
@@ -215,6 +213,30 @@ export class TestHelpers {
}
}
/**
* Clears what Element Web stacks over the room list after sign-in. The
* release announcement is held back while any toast is up, so it is closed
* after the toasts, not before.
*/
public static async clearRoomListOverlays(page: Page): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
}
public static async closeReleaseAnnouncement(
page: Page,
name: string,
): Promise<void> {
try {
await page
.getByRole("dialog", { name })
.getByRole("button", { name: "OK" })
.click({ timeout: 2000 });
} catch {
// Announcement not shown; nothing to do
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
@@ -260,14 +282,15 @@ export class TestHelpers {
/**
* Opens the room list's "New" menu and picks the given item.
*
* Driven by keyboard, as a toast arriving late sits over the button and
* swallows clicks.
* While the sections announcement is anchored to the "New" button, the
* button has no menu at all, so the overlays go first. Driven by keyboard,
* as a toast arriving afterwards sits over the button and swallows clicks.
*/
public static async chooseFromRoomListNewMenu(
page: Page,
itemName: string,
): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.clearRoomListOverlays(page);
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
@@ -404,8 +427,9 @@ export class TestHelpers {
name: "Toggle Invites section",
});
await expect(invitesSection).toBeVisible({ timeout: 10000 });
// Toasts sit over the top of the room list, where this header is.
await TestHelpers.dismissStartupToasts(page);
// Toasts and the announcement sit over the top of the room list, where
// this header is
await TestHelpers.clearRoomListOverlays(page);
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
await invitesSection.click();
}
+17 -23
View File
@@ -8,7 +8,7 @@ overrides:
'@livekit/components-core>rxjs': ^7.8.1
'@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18
tar: ^7.5.11
minimatch: ^11.0.0
minimatch: ^10.2.3
glob: ^10.5.0
qs: ^6.15.2
js-yaml: ^4.2.0
@@ -1145,9 +1145,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/nonrecursive-glob-match@1.0.3':
resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
peerDependencies:
@@ -3533,8 +3530,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -4771,9 +4768,9 @@ packages:
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
minimatch@11.0.4:
resolution: {integrity: sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==}
engines: {node: 20 || >=22}
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -6905,7 +6902,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
minimatch: 11.0.4
minimatch: 10.2.6
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -6984,7 +6981,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
transitivePeerDependencies:
- supports-color
@@ -7120,8 +7117,6 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/nonrecursive-glob-match@1.0.3': {}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
glob: 10.5.0
@@ -8781,7 +8776,7 @@ snapshots:
'@typescript-eslint/types': 8.69.0
'@typescript-eslint/visitor-keys': 8.69.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -8796,7 +8791,7 @@ snapshots:
'@typescript-eslint/types': 8.72.0
'@typescript-eslint/visitor-keys': 8.72.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -9121,7 +9116,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.12:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -9666,7 +9661,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 11.0.4
minimatch: 10.2.6
natural-compare: 1.4.0
optionator: 0.9.4
strip-ansi: 6.0.1
@@ -9786,7 +9781,7 @@ snapshots:
filelist@1.0.6:
dependencies:
minimatch: 11.0.4
minimatch: 10.2.6
fill-range@7.1.1:
dependencies:
@@ -9886,7 +9881,7 @@ snapshots:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 11.0.4
minimatch: 10.2.6
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
@@ -10011,7 +10006,7 @@ snapshots:
jiti: 2.7.0
jsonc-parser: 3.3.1
magic-string: 1.2.3
minimatch: 11.0.4
minimatch: 10.2.6
ora: 9.4.1
react: 19.2.8
react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
@@ -10454,10 +10449,9 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {}
minimatch@11.0.4:
minimatch@10.2.6:
dependencies:
'@isaacs/nonrecursive-glob-match': 1.0.3
brace-expansion: 5.0.12
brace-expansion: 5.0.9
minimist@1.2.8: {}
+1 -1
View File
@@ -27,7 +27,7 @@ overrides:
# Security fixes:
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
"minimatch": "^11.0.0"
"minimatch": "^10.2.3"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
"glob": "^10.5.0"
# Security fixes:
@@ -225,51 +225,6 @@ describe("createLivekitMediaTrack", () => {
);
});
// LiveKit's media stream track is the processed one while a processor runs,
// and that one names no device; the camera behind it does
it.each([
{ deviceId: "cam-1", switches: false },
{ deviceId: "cam-2", switches: true },
])(
"leaves a processed camera alone unless its device changes: $deviceId",
async ({ deviceId, switches }) => {
vi.stubGlobal("MediaStreamTrack", class {});
onTestFinished(() => {
vi.unstubAllGlobals();
});
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
source: Track.Source.Camera,
_mediaStreamTrack: {
readyState: "live",
label: "",
getSettings: () => ({ deviceId: "cam-1" }),
},
// What LiveKit shows as the track, with no device of its own
processor: {
processedTrack: { label: "", getSettings: () => ({}) },
},
}) as LocalVideoTrack;
const switchActiveDevice = vi.fn().mockResolvedValue(true);
const { participant, publication, room } = fakes({
track: camera as unknown as FakeTrack,
});
const track = createLocalLivekitMediaTrack(
scope,
Object.assign(participant, {
isLocal: true,
}) as unknown as LocalParticipant,
publication as unknown as TrackPublication,
Object.assign(room, { switchActiveDevice }),
vi.fn(),
);
await track.setDevice(deviceId);
expect(switchActiveDevice.mock.calls).toEqual(
switches ? [["videoinput", deviceId, false]] : [],
);
},
);
// LiveKit stops a camera that is turned off; the processor is built when it
// is turned on again
it.each([
@@ -199,13 +199,11 @@ export function createLocalLivekitMediaTrack(
throw new MatrixRTCError(`A ${base.source} track has no device`);
// LiveKit decides whether to restart by the constraints it keeps, which
// a browser that does not echo the device id back (Firefox) leaves
// without one; a track already on the device is left alone here. Asked
// of the camera track itself: with a processor on, LiveKit's media
// stream track is the processed one, which names no device
// without one; a track already on the device is left alone here
const current = publication.track;
if (
current instanceof LocalTrack &&
(await current.getDeviceId(false)) === deviceId
current.mediaStreamTrack.getSettings().deviceId === deviceId
)
return;
await room.switchActiveDevice(